v0.3.4: error path prefers .json() with .text() fallback; always decorate 409

Matches the common test pattern of mocking .json() for error responses
(vitest helpers often do that by default and don't bother with .text()).
The error path now clones the response, tries .json() first, and falls
back to .text() on parse failure. Populates both text and json for
formatError callers.

409 errors are always decorated with .status=409 and .body=<json>, even
when the consumer doesn't provide on409 — conflict resolvers at call
sites can still reach the payload.

Default 409 message is the body's .message field if present; otherwise
falls through to the usual format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-21 22:31:56 +02:00
parent 84693a7af5
commit 0673c4c0d8
2 changed files with 32 additions and 15 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "@uschuster/webapp-scaffold", "name": "@uschuster/webapp-scaffold",
"version": "0.3.3", "version": "0.3.4",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.", "description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module", "type": "module",
"bin": { "bin": {

View file

@ -149,24 +149,25 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
} }
if (r.status === 401) opts.on401?.(req); if (r.status === 401) opts.on401?.(req);
if (r.status === 409 && opts.on409) { if (r.status === 409) {
const body = await r.text(); const [text, json] = await readBody(r);
const resolved = opts.on409(req, body); if (opts.on409) {
if (resolved !== undefined) { const resolved = opts.on409(req, text);
return wrap(resolved, r.status, r.headers) as T; if (resolved !== undefined) {
return wrap(resolved, r.status, r.headers) as T;
}
} }
// Fall through to error-formatting path below. const msg = opts.formatError?.(req, r, text, json)
const bodyJson = safeJson(body); ?? (json && typeof (json as Record<string, unknown>).message === 'string'
const msg = opts.formatError?.(req, r, body, bodyJson) ? String((json as Record<string, unknown>).message)
?? `${r.status} ${r.statusText}: ${body}`; : `${r.status} ${r.statusText}: ${text}`);
throw decorate409(new Error(msg), bodyJson); throw decorate409(new Error(msg), json);
} }
if (!r.ok) { if (!r.ok) {
const body = await r.text(); const [text, json] = await readBody(r);
const bodyJson = safeJson(body); const msg = opts.formatError?.(req, r, text, json)
const msg = opts.formatError?.(req, r, body, bodyJson) ?? `${r.status} ${r.statusText}: ${text}`;
?? `${r.status} ${r.statusText}: ${body}`;
throw new Error(msg); throw new Error(msg);
} }
@ -183,6 +184,22 @@ function safeJson(text: string): unknown | null {
try { return JSON.parse(text); } catch { return null; } try { return JSON.parse(text); } catch { return null; }
} }
/**
* Read an error-response body. Prefer `.json()` (tests commonly mock only
* that); fall back to `.text()` if json() throws. Returns both the raw
* text (for `formatError` callers that want to log it) and the parsed
* JSON (may be null when the body isn't parseable).
*/
async function readBody(r: Response): Promise<[string, unknown | null]> {
try {
const j = await r.clone().json();
return [JSON.stringify(j), j];
} catch {
const text = await r.text().catch(() => '');
return [text, safeJson(text)];
}
}
/** Attach status/body to 409 errors so conflict-resolvers can reach them. */ /** Attach status/body to 409 errors so conflict-resolvers can reach them. */
function decorate409(err: Error, body: unknown): Error { function decorate409(err: Error, body: unknown): Error {
(err as Error & { status?: number; body?: unknown }).status = 409; (err as Error & { status?: number; body?: unknown }).status = 409;