Skip to content

Commit b053976

Browse files
authored
Merge pull request #33 from FoundatioFx/fix/response-deserialization-errors
Treat response deserialization failures as request errors
2 parents 6b38f1d + c63cd7b commit b053976

5 files changed

Lines changed: 204 additions & 17 deletions

File tree

docs/guide/error-handling.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,49 @@ if (response.status === 404) {
3535

3636
## Prevent All Throwing
3737

38-
Disable throwing entirely:
38+
Disable throwing for unexpected HTTP status codes:
3939

4040
```ts
4141
const response = await client.getJSON("/api/resource", {
4242
shouldThrowOnUnexpectedStatusCodes: false,
4343
});
4444

45-
// Always returns response, never throws
45+
// Returns responses for unexpected HTTP status codes
4646
if (!response.ok) {
4747
console.log("Request failed:", response.status);
4848
}
4949
```
5050

51+
Response body consumption, cancellation, and deserialization errors are not HTTP
52+
status errors and can still reject the request.
53+
54+
## Response Deserialization Errors
55+
56+
JSON helpers throw `FetchClientDeserializationError` when a successful response
57+
body cannot be read or parsed. The error retains the response, the underlying
58+
cause, and any response text that was read. Invalid JSON is never returned in
59+
`response.data`.
60+
61+
```ts
62+
import { FetchClientDeserializationError } from "@foundatiofx/fetchclient";
63+
64+
try {
65+
await client.getJSON("/api/resource");
66+
} catch (error) {
67+
if (error instanceof FetchClientDeserializationError) {
68+
console.log(error.response.status);
69+
console.log(error.responseText);
70+
console.log(error.cause);
71+
}
72+
}
73+
```
74+
75+
If an `AbortSignal` cancels response body consumption, FetchClient rejects with
76+
the signal's original abort reason instead of wrapping it as a deserialization
77+
error. Middleware observes both cancellation and deserialization failures.
78+
`errorCallback` is invoked for deserialization errors and can explicitly
79+
suppress them by returning `true`; cancellation bypasses `errorCallback`.
80+
5181
## Custom Error Callback
5282

5383
Handle errors with custom logic:

mod.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
export { FetchClient } from "./src/FetchClient.ts";
22
export type { FetchClientOptions } from "./src/FetchClientOptions.ts";
33
export type { FetchClientResponse } from "./src/FetchClientResponse.ts";
4-
export { FetchClientError } from "./src/FetchClientError.ts";
4+
export {
5+
FetchClientDeserializationError,
6+
FetchClientError,
7+
} from "./src/FetchClientError.ts";
58
export { getStatusText } from "./src/HttpStatusText.ts";
69
export { ResponsePromise } from "./src/ResponsePromise.ts";
710
export { ProblemDetails } from "./src/ProblemDetails.ts";

src/FetchClient.ts

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { getCurrentProvider } from "./DefaultHelpers.ts";
1515
import type { FetchClientOptions } from "./FetchClientOptions.ts";
1616
import { type IObjectEvent, ObjectEvent } from "./ObjectEvent.ts";
1717
import { ResponsePromise } from "./ResponsePromise.ts";
18-
import { FetchClientError } from "./FetchClientError.ts";
18+
import {
19+
FetchClientDeserializationError,
20+
FetchClientError,
21+
} from "./FetchClientError.ts";
1922
import { getStatusText } from "./HttpStatusText.ts";
2023

2124
type Fetch = typeof globalThis.fetch;
@@ -497,7 +500,11 @@ export class FetchClient {
497500
"application/problem+json",
498501
)
499502
) {
500-
ctx.response = await this.getJSONResponse<T>(response, ctx.options);
503+
ctx.response = await this.getJSONResponse<T>(
504+
response,
505+
ctx.options,
506+
ctx.request.signal,
507+
);
501508
} else {
502509
ctx.response = response as FetchClientResponse<T>;
503510
ctx.response.data = null;
@@ -558,14 +565,23 @@ export class FetchClient {
558565
meta: {},
559566
};
560567

561-
await this.invokeMiddleware(context, middleware);
562-
563-
this.#counter.decrement();
564-
this.#provider.counter.decrement();
565-
566-
this.validateResponse(context.response, options);
568+
try {
569+
await this.invokeMiddleware(context, middleware);
570+
this.validateResponse(context.response, options);
571+
return context.response as FetchClientResponse<T>;
572+
} catch (error) {
573+
if (
574+
error instanceof FetchClientDeserializationError &&
575+
options.errorCallback?.(error.response) === true
576+
) {
577+
return error.response as FetchClientResponse<T>;
578+
}
567579

568-
return context.response as FetchClientResponse<T>;
580+
throw error;
581+
} finally {
582+
this.#counter.decrement();
583+
this.#provider.counter.decrement();
584+
}
569585
}
570586

571587
private async invokeMiddleware(
@@ -607,6 +623,7 @@ export class FetchClient {
607623
private async getJSONResponse<T>(
608624
response: Response,
609625
options: RequestOptions,
626+
signal: AbortSignal,
610627
): Promise<FetchClientResponse<T>> {
611628
let data = null;
612629
let bodyText = "";
@@ -622,12 +639,37 @@ export class FetchClient {
622639
}
623640
}
624641
} catch (error: unknown) {
625-
data = new ProblemDetails();
626-
data.detail = bodyText;
627-
data.title = `Unable to deserialize response data: ${
642+
if (signal.aborted) {
643+
throw signal.reason;
644+
}
645+
646+
if (error instanceof DOMException && error.name === "AbortError") {
647+
throw error;
648+
}
649+
650+
const problem = new ProblemDetails();
651+
problem.detail = bodyText;
652+
problem.title = `Unable to deserialize response data: ${
628653
error instanceof Error ? error.message : String(error)
629654
}`;
630-
data.setErrorMessage(data.title);
655+
problem.setErrorMessage(problem.title);
656+
657+
if (response.ok) {
658+
const jsonResponse = response as FetchClientResponse<T>;
659+
jsonResponse.data = null;
660+
jsonResponse.problem = problem;
661+
jsonResponse.meta = {
662+
links: parseLinkHeader(response.headers.get("Link")) || {},
663+
};
664+
665+
throw new FetchClientDeserializationError(
666+
jsonResponse,
667+
error,
668+
bodyText,
669+
);
670+
}
671+
672+
data = problem;
631673
}
632674

633675
const jsonResponse = response as FetchClientResponse<T>;

src/FetchClientError.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { FetchClientResponse } from "./FetchClientResponse.ts";
22
import { getStatusText } from "./HttpStatusText.ts";
33

44
/**
5-
* Error wrapper for non-2xx responses.
5+
* Error wrapper for request failures with an HTTP response.
66
* Exposes the underlying response for compatibility and debugging.
77
*/
88
export class FetchClientError extends Error {
@@ -99,3 +99,24 @@ export class FetchClientError extends Error {
9999
return this.response.clone();
100100
}
101101
}
102+
103+
/**
104+
* Error thrown when the body of a successful response cannot be read or
105+
* deserialized as JSON.
106+
*/
107+
export class FetchClientDeserializationError extends FetchClientError {
108+
public override readonly cause: unknown;
109+
public readonly responseText: string;
110+
111+
constructor(
112+
response: FetchClientResponse<unknown>,
113+
cause: unknown,
114+
responseText: string,
115+
) {
116+
const detail = cause instanceof Error ? cause.message : String(cause);
117+
super(response, `Unable to deserialize response data: ${detail}`);
118+
this.name = "FetchClientDeserializationError";
119+
this.cause = cause;
120+
this.responseText = responseText;
121+
}
122+
}

src/tests/ErrorHandling.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import {
33
assertEquals,
44
assertFalse,
55
assertRejects,
6+
assertStrictEquals,
67
assertStringIncludes,
78
} from "@std/assert";
89
import {
910
FetchClient,
11+
FetchClientDeserializationError,
1012
FetchClientError,
1113
type FetchClientResponse,
1214
ProblemDetails,
@@ -260,3 +262,92 @@ Deno.test("problem details are populated on error responses", async () => {
260262
assert(res.problem.errors.server);
261263
assertEquals(res.problem.errors.server[0], "Database connection failed");
262264
});
265+
266+
Deno.test("malformed JSON in a successful response throws a deserialization error", async () => {
267+
const provider = new FetchClientProvider();
268+
provider.fetch = () =>
269+
Promise.resolve(
270+
new Response('{"value":', {
271+
status: 200,
272+
headers: { "Content-Type": "application/json" },
273+
}),
274+
);
275+
276+
const client = provider.getFetchClient();
277+
let callbackResponse: FetchClientResponse<unknown> | undefined;
278+
279+
const error = await assertRejects(
280+
() =>
281+
client.getJSON("https://example.com/malformed", {
282+
errorCallback: (response) => {
283+
callbackResponse = response;
284+
return false;
285+
},
286+
}),
287+
FetchClientDeserializationError,
288+
);
289+
290+
assert(error instanceof FetchClientDeserializationError);
291+
assert(error.cause instanceof SyntaxError);
292+
assertEquals(error.responseText, '{"value":');
293+
assertEquals(error.response.status, 200);
294+
assert(error.response.ok);
295+
assertEquals(error.response.data, null);
296+
assertStringIncludes(
297+
error.response.problem.title ?? "",
298+
"Unable to deserialize response data",
299+
);
300+
assertStrictEquals(callbackResponse, error.response);
301+
assertEquals(client.requestCount, 0);
302+
assertEquals(provider.requestCount, 0);
303+
});
304+
305+
Deno.test("aborting a successful response body read preserves the abort reason", async () => {
306+
const provider = new FetchClientProvider();
307+
provider.fetch = (request) => {
308+
const signal = request instanceof Request
309+
? request.signal
310+
: new Request(request).signal;
311+
const body = new ReadableStream<Uint8Array>({
312+
start(controller) {
313+
controller.enqueue(new TextEncoder().encode('{"value":'));
314+
signal.addEventListener(
315+
"abort",
316+
() => controller.error(signal.reason),
317+
{ once: true },
318+
);
319+
},
320+
});
321+
322+
return Promise.resolve(
323+
new Response(body, {
324+
status: 200,
325+
headers: { "Content-Type": "application/json" },
326+
}),
327+
);
328+
};
329+
330+
const client = provider.getFetchClient();
331+
const abortController = new AbortController();
332+
const abortReason = new DOMException("Query cancelled", "AbortError");
333+
let middlewareSawAbort = false;
334+
client.use(async (_context, next) => {
335+
try {
336+
await next();
337+
} catch (error) {
338+
middlewareSawAbort = error === abortReason;
339+
throw error;
340+
}
341+
});
342+
343+
const request = client.getJSON("https://example.com/stream", {
344+
signal: abortController.signal,
345+
});
346+
setTimeout(() => abortController.abort(abortReason), 10);
347+
348+
const error = await assertRejects(() => request);
349+
assertStrictEquals(error, abortReason);
350+
assert(middlewareSawAbort);
351+
assertEquals(client.requestCount, 0);
352+
assertEquals(provider.requestCount, 0);
353+
});

0 commit comments

Comments
 (0)