From ce2e94869a25959b66392522eb02367bf56dfe02 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Mon, 5 Oct 2026 07:56:33 -0400 Subject: [PATCH] Refuse a GET request with a body before fetching so it never marks the network offline fetch rejects a GET with a body with a TypeError before sending anything, which the client took for a network failure and showed the offline pill. apiFetch now refuses such a request with its own error before calling fetch. Co-Authored-By: Claude Opus 5.5 --- web/src/api/client.ts | 7 ++++++- web/src/pwa/OfflinePill.test.tsx | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3ad4074..22d6c10 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,7 +37,7 @@ export type ApiLocation = Pick(path: string, request: ApiRequest = {}): Promise { const method = request.method ?? 'GET' const url = apiURL(location.origin, method, path) + // fetch refuses a GET with a body by rejecting with a TypeError, the same + // error as a network failure, so refuse it here before anything is sent. + if (method === 'GET' && request.body !== undefined) { + throw new Error(`GET ${path}: a GET request cannot carry a body`) + } let response = await send(method, url, request.body) if (response.status === 403) { const error = await parseForbidden(method, path, response) diff --git a/web/src/pwa/OfflinePill.test.tsx b/web/src/pwa/OfflinePill.test.tsx index 868c650..9a06880 100644 --- a/web/src/pwa/OfflinePill.test.tsx +++ b/web/src/pwa/OfflinePill.test.tsx @@ -96,6 +96,19 @@ describe('OfflinePill', () => { expect(screen.queryByRole('status')).toBeNull() }) + it('ignores a GET request with a body, which fetch would refuse to send', async () => { + const api = renderPill() + const fetchMock = stubFetch() + + await act(() => + expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow( + 'GET /api/v1/plan: a GET request cannot carry a body', + ), + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(screen.queryByRole('status')).toBeNull() + }) + it('ignores an aborted request', async () => { const api = renderPill() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError')))