Skip to content

Commit a5b9b4b

Browse files
Copilotnzakas
andauthored
feat: Serialize errors as JSON in Response body (#7)
* Initial plan * Implement JSON error serialization in Response body Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Add safety checks for circular references and non-serializable properties Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Refactor to eliminate code duplication Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * docs: Update README with error serialization examples Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Add Content-Type: application/json header to error responses Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
1 parent 4d23902 commit a5b9b4b

3 files changed

Lines changed: 230 additions & 4 deletions

File tree

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ if (response.ok) {
4040
// the ERROR_STATUS indicates it's a caught error
4141
console.error("Error:", response.statusText);
4242
// "This operation was aborted"
43+
44+
// You can also access the error details from the response body
45+
const errorDetails = await response.json();
46+
console.error("Error details:", errorDetails);
47+
// { message: "This operation was aborted", stack: "..." }
4348
} else {
4449
// Handle HTTP errors (non-2xx status codes)
4550
console.error(`HTTP Error: ${response.status} ${response.statusText}`);
@@ -66,6 +71,10 @@ if (response.ok) {
6671
console.log("Success!");
6772
} else if (response.status === ERROR_STATUS) {
6873
console.error("Error:", response.statusText);
74+
75+
// Access detailed error information from the response body
76+
const errorDetails = await response.json();
77+
console.error("Error details:", errorDetails);
6978
} else {
7079
// Handle HTTP errors (non-2xx status codes)
7180
console.error(`HTTP Error: ${response.status} ${response.statusText}`);
@@ -87,6 +96,10 @@ if (response.ok) {
8796
console.log(data);
8897
} else if (response.status === ERROR_STATUS) {
8998
console.error("Error:", response.statusText);
99+
100+
// Get detailed error information from the response body
101+
const errorDetails = await response.json();
102+
console.error("Error details:", errorDetails);
90103
} else {
91104
// Handle HTTP errors (non-2xx status codes)
92105
console.error(`HTTP Error: ${response.status} ${response.statusText}`);
@@ -103,6 +116,55 @@ When a fetch operation fails (network error, abort signal, etc.), instead of rej
103116

104117
- `status`: `ERROR_STATUS` (10001)
105118
- `statusText`: The error message
119+
- `body`: JSON-serialized error details
120+
121+
### Error Body Serialization
122+
123+
The error details are serialized as JSON in the response body, making it easy to access structured error information:
124+
125+
- **String errors**: Serialized as `{ message: "error string" }`
126+
- **Error objects**: All properties (including `message`, `stack`, and custom properties) are extracted and serialized
127+
128+
**Example with Error object:**
129+
130+
```javascript
131+
import { safeFetch, ERROR_STATUS } from "@humanwhocodes/safe-fetch";
132+
133+
const response = await safeFetch("https://invalid-domain.example");
134+
135+
if (response.status === ERROR_STATUS) {
136+
const error = await response.json();
137+
console.log(error.message); // "Failed to fetch"
138+
console.log(error.stack); // Stack trace
139+
}
140+
```
141+
142+
**Example with custom error properties:**
143+
144+
```javascript
145+
const mockFetch = async () => {
146+
const error = new Error("Database connection failed");
147+
error.code = "DB_CONN_ERROR";
148+
error.retryAfter = 5000;
149+
throw error;
150+
};
151+
152+
const safeMockFetch = createSafeFetch(mockFetch);
153+
const response = await safeMockFetch("https://api.example.com/data");
154+
155+
if (response.status === ERROR_STATUS) {
156+
const error = await response.json();
157+
console.log(error.message); // "Database connection failed"
158+
console.log(error.code); // "DB_CONN_ERROR"
159+
console.log(error.retryAfter); // 5000
160+
}
161+
```
162+
163+
**Safety features:**
164+
165+
- Circular references are handled gracefully with a fallback to a simple message format
166+
- Non-serializable properties (functions, symbols) are automatically filtered out
167+
- Property access errors are caught and handled
106168

107169
## License
108170

src/index.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,53 @@ export const ERROR_STATUS = 10001;
1919
export function createSafeFetch(fetch) {
2020
return (url, init) => {
2121
return fetch(url, init).catch(error => {
22+
// Serialize error to JSON
23+
/** @type {Record<string, any>} */
24+
let errorObject;
25+
const errorMessage =
26+
typeof error === "string" ? error : error.message || "Unknown error";
27+
28+
if (typeof error === "string") {
29+
errorObject = { message: error };
30+
} else {
31+
// Extract all properties from the error object
32+
errorObject = {};
33+
const propertyNames = Object.getOwnPropertyNames(error);
34+
35+
for (const name of propertyNames) {
36+
try {
37+
const value = error[name];
38+
39+
// Skip functions and symbols as they can't be serialized
40+
if (
41+
typeof value !== "function" &&
42+
typeof value !== "symbol"
43+
) {
44+
errorObject[name] = value;
45+
}
46+
} catch {
47+
// Skip properties that throw on access
48+
}
49+
}
50+
}
51+
52+
// Safely stringify with circular reference handling
53+
let body;
54+
55+
try {
56+
body = JSON.stringify(errorObject);
57+
} catch {
58+
// Fallback if serialization fails (e.g., circular references)
59+
body = JSON.stringify({ message: errorMessage });
60+
}
61+
2262
// Create a custom Response-like object since ERROR_STATUS is out of valid range
23-
const statusText =
24-
typeof error === "string" ? error : error.message;
25-
const response = new Response(null, {
63+
const response = new Response(body, {
2664
status: 599,
27-
statusText,
65+
statusText: errorMessage,
66+
headers: {
67+
"Content-Type": "application/json",
68+
},
2869
});
2970

3071
// Override the status property with a custom value

tests/index.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,129 @@ describe("createSafeFetch", () => {
126126
assert.strictEqual(response.status, 10001);
127127
assert.strictEqual(response.statusText, errorMessage);
128128
});
129+
130+
it("should serialize string errors as JSON in response body", async () => {
131+
const errorMessage = "String error message";
132+
const mockFetch = async () => {
133+
throw errorMessage;
134+
};
135+
const safe = createSafeFetch(mockFetch);
136+
const response = await safe("https://example.com");
137+
138+
const body = await response.json();
139+
140+
assert.deepStrictEqual(body, { message: errorMessage });
141+
});
142+
143+
it("should serialize Error object properties as JSON in response body", async () => {
144+
const errorMessage = "Network error occurred";
145+
const mockFetch = async () => {
146+
throw new Error(errorMessage);
147+
};
148+
const safe = createSafeFetch(mockFetch);
149+
const response = await safe("https://example.com");
150+
151+
const body = await response.json();
152+
153+
assert.strictEqual(body.message, errorMessage);
154+
assert.ok("stack" in body);
155+
});
156+
157+
it("should serialize custom error object with additional properties", async () => {
158+
const mockFetch = async () => {
159+
const error = new Error("Custom error");
160+
error.code = "ERR_CUSTOM";
161+
error.statusCode = 500;
162+
throw error;
163+
};
164+
const safe = createSafeFetch(mockFetch);
165+
const response = await safe("https://example.com");
166+
167+
const body = await response.json();
168+
169+
assert.strictEqual(body.message, "Custom error");
170+
assert.strictEqual(body.code, "ERR_CUSTOM");
171+
assert.strictEqual(body.statusCode, 500);
172+
assert.ok("stack" in body);
173+
});
174+
175+
it("should serialize TypeError properties as JSON in response body", async () => {
176+
const errorMessage = "Failed to fetch";
177+
const mockFetch = async () => {
178+
throw new TypeError(errorMessage);
179+
};
180+
const safe = createSafeFetch(mockFetch);
181+
const response = await safe("https://example.com");
182+
183+
const body = await response.json();
184+
185+
assert.strictEqual(body.message, errorMessage);
186+
assert.ok("stack" in body);
187+
});
188+
189+
it("should serialize plain object errors as JSON in response body", async () => {
190+
const errorObject = {
191+
message: "Plain object error",
192+
code: 123,
193+
details: "Some details",
194+
};
195+
const mockFetch = async () => {
196+
throw errorObject;
197+
};
198+
const safe = createSafeFetch(mockFetch);
199+
const response = await safe("https://example.com");
200+
201+
const body = await response.json();
202+
203+
assert.deepStrictEqual(body, errorObject);
204+
});
205+
206+
it("should handle circular references in error objects", async () => {
207+
const mockFetch = async () => {
208+
const error = new Error("Circular reference error");
209+
error.self = error; // Create circular reference
210+
throw error;
211+
};
212+
const safe = createSafeFetch(mockFetch);
213+
const response = await safe("https://example.com");
214+
215+
const body = await response.json();
216+
217+
// Should fallback to simple message format
218+
assert.strictEqual(body.message, "Circular reference error");
219+
});
220+
221+
it("should skip non-serializable properties like functions", async () => {
222+
const mockFetch = async () => {
223+
const error = new Error("Error with function");
224+
error.myFunction = () => {
225+
return "test";
226+
};
227+
error.normalProp = "value";
228+
throw error;
229+
};
230+
const safe = createSafeFetch(mockFetch);
231+
const response = await safe("https://example.com");
232+
233+
const body = await response.json();
234+
235+
assert.strictEqual(body.message, "Error with function");
236+
assert.strictEqual(body.normalProp, "value");
237+
assert.ok(!("myFunction" in body));
238+
});
239+
240+
it("should set Content-Type header to application/json", async () => {
241+
const mockFetch = async () => {
242+
throw new Error("Test error");
243+
};
244+
const safe = createSafeFetch(mockFetch);
245+
const response = await safe("https://example.com");
246+
247+
assert.strictEqual(
248+
response.headers.get("Content-Type"),
249+
"application/json",
250+
);
251+
});
129252
});
130253

131254
describe("safeFetch", () => {

0 commit comments

Comments
 (0)