Serialize the request body before fetching so it never marks the network offline
CI / ci (push) Successful in 1m33s
CI / ci (pull_request) Successful in 1m33s

A body JSON.stringify cannot serialize, such as a cyclic one, threw inside
the fetch try/catch and was recorded as a network failure, showing the
offline pill. It now fails before the fetch with its own error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 07:49:15 -04:00
co-authored by Claude Opus 5.5
parent c1e49db87f
commit 0573b39f6d
2 changed files with 26 additions and 7 deletions
+9 -6
View File
@@ -74,14 +74,17 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus):
}
headers[CSRF_HEADER] = csrfToken
}
// Serialize before fetching so a body that cannot be sent is its own error
// and never counts as a network failure.
let json: string | undefined
try {
json = body === undefined ? undefined : JSON.stringify(body)
} catch (error) {
throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error })
}
let response: Response
try {
response = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
})
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
} catch (error) {
// fetch rejects with a TypeError when the network fails; an abort is a
// DOMException and says nothing about the network.
+17 -1
View File
@@ -1,7 +1,7 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, type ApiClient } from '../api/index.ts'
import { fakeLocation, jsonResponse, stubFetch } from '../test/fetchStub.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { createNetworkStatus } from './networkStatus.ts'
import { OfflinePill } from './OfflinePill.tsx'
@@ -80,6 +80,22 @@ describe('OfflinePill', () => {
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a request body that cannot be serialized', async () => {
const api = renderPill()
const fetchMock = stubFetch(jsonResponse(200, ME))
await act(() => api.loadMe())
const body: Record<string, unknown> = {}
body.self = body
await act(() =>
expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow(
'PUT /api/v1/plan: request body is not serializable as JSON',
),
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores an aborted request', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError')))