@gusnips/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +323 -0
- package/dist/errors.d.ts +190 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +154 -0
- package/dist/errors.js.map +1 -0
- package/dist/hono/errors.d.ts +46 -0
- package/dist/hono/errors.d.ts.map +1 -0
- package/dist/hono/errors.js +54 -0
- package/dist/hono/errors.js.map +1 -0
- package/dist/hono/guards.d.ts +45 -0
- package/dist/hono/guards.d.ts.map +1 -0
- package/dist/hono/guards.js +88 -0
- package/dist/hono/guards.js.map +1 -0
- package/dist/hono/index.d.ts +18 -0
- package/dist/hono/index.d.ts.map +1 -0
- package/dist/hono/index.js +15 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/hono/request-logger.d.ts +31 -0
- package/dist/hono/request-logger.d.ts.map +1 -0
- package/dist/hono/request-logger.js +57 -0
- package/dist/hono/request-logger.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/logger/index.d.ts +44 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +74 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/serialize.d.ts +68 -0
- package/dist/logger/serialize.d.ts.map +1 -0
- package/dist/logger/serialize.js +203 -0
- package/dist/logger/serialize.js.map +1 -0
- package/dist/responses.d.ts +107 -0
- package/dist/responses.d.ts.map +1 -0
- package/dist/responses.js +183 -0
- package/dist/responses.js.map +1 -0
- package/package.json +79 -0
- package/src/errors.test.ts +93 -0
- package/src/errors.ts +264 -0
- package/src/errors.types.test.ts +96 -0
- package/src/hono/errors.test.ts +215 -0
- package/src/hono/errors.ts +86 -0
- package/src/hono/guards.test.ts +234 -0
- package/src/hono/guards.ts +107 -0
- package/src/hono/index.ts +17 -0
- package/src/hono/request-logger.test.ts +200 -0
- package/src/hono/request-logger.ts +77 -0
- package/src/index.ts +6 -0
- package/src/logger/index.test.ts +137 -0
- package/src/logger/index.ts +112 -0
- package/src/logger/serialize.test.ts +300 -0
- package/src/logger/serialize.ts +202 -0
- package/src/readme.test.ts +132 -0
- package/src/responses.test.ts +291 -0
- package/src/responses.ts +277 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { AppError, createAppError } from "./errors.ts";
|
|
4
|
+
import { created, createErrorResponse, noContent, ok, paginated } from "./responses.ts";
|
|
5
|
+
|
|
6
|
+
type Code =
|
|
7
|
+
| "VALIDATION_ERROR"
|
|
8
|
+
| "UNAUTHORIZED"
|
|
9
|
+
| "NOT_FOUND"
|
|
10
|
+
| "RATE_LIMIT_EXCEEDED"
|
|
11
|
+
| "INTERNAL_ERROR"
|
|
12
|
+
| "SERVICE_UNAVAILABLE"
|
|
13
|
+
| "GATEWAY_ERROR";
|
|
14
|
+
|
|
15
|
+
const ERROR_STATUS = {
|
|
16
|
+
VALIDATION_ERROR: 400,
|
|
17
|
+
UNAUTHORIZED: 401,
|
|
18
|
+
NOT_FOUND: 404,
|
|
19
|
+
RATE_LIMIT_EXCEEDED: 429,
|
|
20
|
+
INTERNAL_ERROR: 500,
|
|
21
|
+
SERVICE_UNAVAILABLE: 503,
|
|
22
|
+
GATEWAY_ERROR: 502,
|
|
23
|
+
} as const satisfies Record<Code, number>;
|
|
24
|
+
|
|
25
|
+
const appError = createAppError<typeof ERROR_STATUS>(ERROR_STATUS);
|
|
26
|
+
|
|
27
|
+
const errorResponse = createErrorResponse<Code>({
|
|
28
|
+
internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
|
|
29
|
+
validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("success helpers", () => {
|
|
33
|
+
it("wraps every 2xx body in { data }", () => {
|
|
34
|
+
expect(ok({ id: 1 })).toEqual({ status: 200, body: { data: { id: 1 } } });
|
|
35
|
+
expect(created({ id: 1 })).toEqual({ status: 201, body: { data: { id: 1 } } });
|
|
36
|
+
expect(noContent()).toEqual({ status: 204, body: null });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("omits meta when there is none", () => {
|
|
40
|
+
expect(ok({ id: 1 }).body).not.toHaveProperty("meta");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("computes hasMore from the page it actually returned", () => {
|
|
44
|
+
expect(paginated([1, 2], { total: 10, limit: 2, offset: 0 }).body.meta).toEqual({
|
|
45
|
+
total: 10,
|
|
46
|
+
limit: 2,
|
|
47
|
+
offset: 0,
|
|
48
|
+
hasMore: true,
|
|
49
|
+
});
|
|
50
|
+
expect(paginated([9, 10], { total: 10, limit: 2, offset: 8 }).body.meta?.hasMore).toBe(false);
|
|
51
|
+
expect(paginated([], { total: 0, limit: 20, offset: 0 }).body.meta?.hasMore).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("the error envelope", () => {
|
|
56
|
+
it("omits every optional field that was not set", () => {
|
|
57
|
+
expect(errorResponse(appError("NOT_FOUND", "Workspace not found")).body).toEqual({
|
|
58
|
+
error: { code: "NOT_FOUND", message: "Workspace not found" },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("carries messageKey, params and details when they are set", () => {
|
|
63
|
+
const body = errorResponse(
|
|
64
|
+
appError("NOT_FOUND", "No such plan", {
|
|
65
|
+
messageKey: "serverErrors.notFound",
|
|
66
|
+
params: { id: "pro" },
|
|
67
|
+
details: { id: "pro", upgradeTo: "starter" },
|
|
68
|
+
}),
|
|
69
|
+
).body;
|
|
70
|
+
expect(body.error.messageKey).toBe("serverErrors.notFound");
|
|
71
|
+
expect(body.error.params).toEqual({ id: "pro" });
|
|
72
|
+
expect(body.error.details).toEqual({ id: "pro", upgradeTo: "starter" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("folds a stated wait into an object details", () => {
|
|
76
|
+
const err = appError("RATE_LIMIT_EXCEEDED", "Too many requests", {
|
|
77
|
+
details: { limit: 60 },
|
|
78
|
+
retryAfterSecs: 30,
|
|
79
|
+
});
|
|
80
|
+
expect(errorResponse(err).body.error.details).toEqual({ limit: 60, retryAfterSecs: 30 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("folds it into an absent details", () => {
|
|
84
|
+
const err = appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs: 30 });
|
|
85
|
+
expect(errorResponse(err).body.error.details).toEqual({ retryAfterSecs: 30 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("hands an ARRAY details back untouched", () => {
|
|
89
|
+
const issues = [{ path: ["url"], code: "invalid_string" }];
|
|
90
|
+
const err = appError("RATE_LIMIT_EXCEEDED", "Too many requests", {
|
|
91
|
+
details: issues,
|
|
92
|
+
retryAfterSecs: 5,
|
|
93
|
+
});
|
|
94
|
+
const details = errorResponse(err).body.error.details;
|
|
95
|
+
expect(details).toEqual(issues);
|
|
96
|
+
expect(Array.isArray(details)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("hands a STRING details back untouched", () => {
|
|
100
|
+
const err = appError("RATE_LIMIT_EXCEEDED", "Spent", {
|
|
101
|
+
details: "month",
|
|
102
|
+
retryAfterSecs: 5,
|
|
103
|
+
});
|
|
104
|
+
expect(errorResponse(err).body.error.details).toBe("month");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("a validation failure is a 400 that reflects nothing back", () => {
|
|
109
|
+
const schema = z
|
|
110
|
+
.object({ days: z.enum(["7", "30"]), timeoutMs: z.number().max(30_000) })
|
|
111
|
+
.strict();
|
|
112
|
+
|
|
113
|
+
it("answers 400 with the path and the rule", () => {
|
|
114
|
+
const parsed = schema.safeParse({ days: "90", timeoutMs: 1 });
|
|
115
|
+
const answer = errorResponse(parsed.error);
|
|
116
|
+
expect(answer.status).toBe(400);
|
|
117
|
+
expect(answer.kind).toBe("client");
|
|
118
|
+
expect(answer.body.error.code).toBe("VALIDATION_ERROR");
|
|
119
|
+
expect(answer.body.error.details).toEqual([{ path: ["days"], code: "invalid_value" }]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("carries the BOUND a range failed against, because the bound is our published contract", () => {
|
|
123
|
+
const parsed = schema.safeParse({ days: "7", timeoutMs: 999_999 });
|
|
124
|
+
expect(errorResponse(parsed.error).body.error.details).toEqual([
|
|
125
|
+
{ path: ["timeoutMs"], code: "too_big", maximum: 30_000 },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("never reflects the rejected value or the keys the caller sent", () => {
|
|
130
|
+
const parsed = schema.safeParse({ days: "90", timeoutMs: 1, secretGuess: "admin" });
|
|
131
|
+
const details = JSON.stringify(errorResponse(parsed.error).body.error.details);
|
|
132
|
+
expect(details).not.toContain("90");
|
|
133
|
+
expect(details).not.toContain("secretGuess");
|
|
134
|
+
expect(details).not.toContain("admin");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("recognizes a ZodError structurally, with no zod import of its own", () => {
|
|
138
|
+
const hand = {
|
|
139
|
+
name: "ZodError",
|
|
140
|
+
issues: [{ path: ["a"], code: "custom", received: "sekrit" }],
|
|
141
|
+
};
|
|
142
|
+
const answer = errorResponse(hand);
|
|
143
|
+
expect(answer.status).toBe(400);
|
|
144
|
+
expect(JSON.stringify(answer.body.error.details)).not.toContain("sekrit");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("an AppError under 500 is answered as itself", () => {
|
|
149
|
+
it("keeps the status, the code and the message", () => {
|
|
150
|
+
const answer = errorResponse(appError("NOT_FOUND", "Workspace not found"));
|
|
151
|
+
expect(answer).toMatchObject({
|
|
152
|
+
status: 404,
|
|
153
|
+
kind: "client",
|
|
154
|
+
body: { error: { code: "NOT_FOUND", message: "Workspace not found" } },
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("names the scheme it wants on a 401", () => {
|
|
159
|
+
expect(errorResponse(appError("UNAUTHORIZED", "Sign in")).headers).toEqual({
|
|
160
|
+
"WWW-Authenticate": "Bearer",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("says a refusal does NOT clear by waiting, and sends no Retry-After for it", () => {
|
|
165
|
+
// A concurrency slot frees when another job finishes, and a cap on live objects clears by
|
|
166
|
+
// archiving one. Neither is a wait a server can state, and both are 429s in the fleet.
|
|
167
|
+
const answer = errorResponse(
|
|
168
|
+
appError("RATE_LIMIT_EXCEEDED", "Three jobs already running — wait for one to finish", {
|
|
169
|
+
retryAfterSecs: null,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
expect(answer.headers).not.toHaveProperty("Retry-After");
|
|
173
|
+
expect(answer.body.error.details).toEqual({ retryAfterSecs: null });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("states a wait in the header as well as in details", () => {
|
|
177
|
+
const answer = errorResponse(
|
|
178
|
+
appError("RATE_LIMIT_EXCEEDED", "Slow down", { retryAfterSecs: 45 }),
|
|
179
|
+
);
|
|
180
|
+
expect(answer.headers["Retry-After"]).toBe("45");
|
|
181
|
+
expect(answer.body.error.details).toEqual({ retryAfterSecs: 45 });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("the 5xx mask", () => {
|
|
186
|
+
it("replaces the message of the one code that may carry internals", () => {
|
|
187
|
+
const answer = errorResponse(
|
|
188
|
+
appError("INTERNAL_ERROR", 'duplicate key value violates "users_email_key"'),
|
|
189
|
+
);
|
|
190
|
+
expect(answer.status).toBe(500);
|
|
191
|
+
expect(answer.kind).toBe("server");
|
|
192
|
+
expect(answer.body.error.message).toBe("Something on our side failed");
|
|
193
|
+
expect(JSON.stringify(answer.body)).not.toContain("users_email_key");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("drops a masked 5xx's details entirely, whatever was put in them", () => {
|
|
197
|
+
// The trap this closes: a driver error handed straight to `details`. Postgres writes the
|
|
198
|
+
// ENTIRE failing row into its `detail` field on a CHECK or NOT NULL violation — every
|
|
199
|
+
// column, values included — so a caught driver object in `details` is a row on the wire.
|
|
200
|
+
const err = appError("INTERNAL_ERROR", "insert failed", {
|
|
201
|
+
details: { detail: "Failing row contains (someone@example.com, 4242424242424242)." },
|
|
202
|
+
});
|
|
203
|
+
const answer = errorResponse(err);
|
|
204
|
+
expect(answer.body.error).not.toHaveProperty("details");
|
|
205
|
+
expect(JSON.stringify(answer)).not.toContain("4242");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("lets an authored 5xx through, because it is what says whether to retry", () => {
|
|
209
|
+
const answer = errorResponse(appError("SERVICE_UNAVAILABLE", "Metering is unavailable"));
|
|
210
|
+
expect(answer.status).toBe(503);
|
|
211
|
+
expect(answer.body.error.message).toBe("Metering is unavailable");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("keeps the STATUS when it masks the message", () => {
|
|
215
|
+
const all = createErrorResponse<Code>({
|
|
216
|
+
internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
|
|
217
|
+
validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
|
|
218
|
+
maskAll: true,
|
|
219
|
+
});
|
|
220
|
+
const answer = all(appError("GATEWAY_ERROR", "upstream said: connection reset by peer"));
|
|
221
|
+
expect(answer.status).toBe(502);
|
|
222
|
+
expect(answer.body.error.message).toBe("Something on our side failed");
|
|
223
|
+
expect(answer.body.error.code).toBe("INTERNAL_ERROR");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("lets `expose` opt an authored sentence back through maskAll", () => {
|
|
227
|
+
const all = createErrorResponse<Code>({
|
|
228
|
+
internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
|
|
229
|
+
validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
|
|
230
|
+
maskAll: true,
|
|
231
|
+
});
|
|
232
|
+
const err = appError("SERVICE_UNAVAILABLE", "Payments are not set up here", { expose: true });
|
|
233
|
+
expect(all(err).body.error.message).toBe("Payments are not set up here");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("drops a 5xx's details on its own knob, exposed or not", () => {
|
|
237
|
+
const quiet = createErrorResponse<Code>({
|
|
238
|
+
internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
|
|
239
|
+
validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
|
|
240
|
+
maskDetails: true,
|
|
241
|
+
});
|
|
242
|
+
const err = appError("SERVICE_UNAVAILABLE", "A dependency is down", {
|
|
243
|
+
details: { pg: "connection refused at 10.0.0.4:5432" },
|
|
244
|
+
});
|
|
245
|
+
expect(quiet(err).body.error.message).toBe("A dependency is down");
|
|
246
|
+
expect(quiet(err).body.error).not.toHaveProperty("details");
|
|
247
|
+
expect(JSON.stringify(quiet(err))).not.toContain("10.0.0.4");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("keeps a 5xx's details by default, because they name which dependency is down", () => {
|
|
251
|
+
const err = appError("SERVICE_UNAVAILABLE", "Not ready", {
|
|
252
|
+
details: { pg: "down", redis: "ok" },
|
|
253
|
+
});
|
|
254
|
+
expect(errorResponse(err).body.error.details).toEqual({ pg: "down", redis: "ok" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("leaves a 4xx's details alone whatever the mask says", () => {
|
|
258
|
+
const quiet = createErrorResponse<Code>({
|
|
259
|
+
internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
|
|
260
|
+
validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
|
|
261
|
+
maskAll: true,
|
|
262
|
+
maskDetails: true,
|
|
263
|
+
});
|
|
264
|
+
const err = appError("NOT_FOUND", "No such plan", { details: { id: "pro" } });
|
|
265
|
+
expect(quiet(err).body.error.details).toEqual({ id: "pro" });
|
|
266
|
+
expect(quiet(err).body.error.message).toBe("No such plan");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("a throw nobody raised on purpose", () => {
|
|
271
|
+
it("answers a generic 500 and never repeats what it caught", () => {
|
|
272
|
+
const answer = errorResponse(new Error("ECONNREFUSED redis://10.0.0.4:6379"));
|
|
273
|
+
expect(answer.status).toBe(500);
|
|
274
|
+
expect(answer.kind).toBe("unexpected");
|
|
275
|
+
expect(answer.body.error.code).toBe("INTERNAL_ERROR");
|
|
276
|
+
expect(JSON.stringify(answer)).not.toContain("10.0.0.4");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("does the same for a plain object a driver rejected with", () => {
|
|
280
|
+
const answer = errorResponse({ code: "PGRST205", message: 'Could not find table "app.jobs"' });
|
|
281
|
+
expect(answer.status).toBe(500);
|
|
282
|
+
expect(answer.kind).toBe("unexpected");
|
|
283
|
+
expect(JSON.stringify(answer)).not.toContain("app.jobs");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("separates a 5xx we raised from one that escaped", () => {
|
|
287
|
+
expect(errorResponse(appError("INTERNAL_ERROR", "we broke it")).kind).toBe("server");
|
|
288
|
+
expect(errorResponse(new AppError(500, "INTERNAL_ERROR", "we broke it")).kind).toBe("server");
|
|
289
|
+
expect(errorResponse("a string nobody expected").kind).toBe("unexpected");
|
|
290
|
+
});
|
|
291
|
+
});
|
package/src/responses.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a backend answers: the success envelope, and the one function that turns a thrown thing
|
|
3
|
+
* into an HTTP answer.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here knows about a framework, and that is a measurement rather than a preference.
|
|
6
|
+
* Five backends put this logic inside one `app.onError`, and then **three of them re-derived
|
|
7
|
+
* the same two arms inside an MCP tool wrapper** so an agent would get a real refusal instead
|
|
8
|
+
* of "an unexpected error occurred". A fourth re-derived the error body inside a background
|
|
9
|
+
* worker's health handler and got it wrong, answering a caught Redis message on a 503 — the
|
|
10
|
+
* API next door masks exactly that. A `Context`-shaped function would serve one of those four
|
|
11
|
+
* callers. The framework adapter is eight lines and lives in `/hono`.
|
|
12
|
+
*/
|
|
13
|
+
import type { ApiError, ApiSuccess, PaginationMeta } from "@gusnips/http";
|
|
14
|
+
import { AppError } from "./errors.ts";
|
|
15
|
+
|
|
16
|
+
/** Every 2xx body is `{ data }`, or `{ data, meta }` where a route has counts to report. */
|
|
17
|
+
export function ok<T, M = PaginationMeta>(data: T, meta?: M) {
|
|
18
|
+
const body: ApiSuccess<T, M> = meta === undefined ? { data } : { data, meta };
|
|
19
|
+
return { status: 200 as const, body };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function created<T>(data: T) {
|
|
23
|
+
const body: ApiSuccess<T> = { data };
|
|
24
|
+
return { status: 201 as const, body };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `hasMore` is computed from the page that was actually returned, not from `limit`: a page cut
|
|
29
|
+
* short by a filter still has to answer the question honestly. Identical, to the field, in five
|
|
30
|
+
* donors.
|
|
31
|
+
*/
|
|
32
|
+
export function paginated<T>(rows: T[], meta: Omit<PaginationMeta, "hasMore">) {
|
|
33
|
+
const body: ApiSuccess<T[]> = {
|
|
34
|
+
data: rows,
|
|
35
|
+
meta: { ...meta, hasMore: meta.offset + rows.length < meta.total },
|
|
36
|
+
};
|
|
37
|
+
return { status: 200 as const, body };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function noContent() {
|
|
41
|
+
return { status: 204 as const, body: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ErrorAnswer<Code extends string = string> {
|
|
45
|
+
status: number;
|
|
46
|
+
body: ApiError<Code>;
|
|
47
|
+
/** `Retry-After` when the refusal states a wait; `WWW-Authenticate` on a 401. */
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
/**
|
|
50
|
+
* What kind of failure this was, which is the one thing a caller cannot work out from the
|
|
51
|
+
* status. A 500 raised on purpose and a `TypeError` that escaped are both 500s, and only the
|
|
52
|
+
* second one means nobody is watching a log for it — every donor fires its admin alert on
|
|
53
|
+
* exactly that branch.
|
|
54
|
+
*/
|
|
55
|
+
kind: "client" | "server" | "unexpected";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The envelope a masked 5xx, an unexpected throw, or a refused body is answered with. */
|
|
59
|
+
export interface CannedError<Code extends string, Key extends string> {
|
|
60
|
+
code: Code;
|
|
61
|
+
/** English, for logs, `curl` and agents. A client localizes from `messageKey`. */
|
|
62
|
+
message: string;
|
|
63
|
+
messageKey?: Key;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ErrorResponseOptions<Code extends string, Key extends string> {
|
|
67
|
+
/** Answers a masked 5xx and anything that escaped. */
|
|
68
|
+
internal: CannedError<Code, Key>;
|
|
69
|
+
/** Answers a validation failure. */
|
|
70
|
+
validation: CannedError<Code, Key>;
|
|
71
|
+
/**
|
|
72
|
+
* The 5xx codes whose message is replaced. Defaults to `["INTERNAL_ERROR"]`, which is what
|
|
73
|
+
* four of the five newest donors do, and their reason is worth keeping: flattening a
|
|
74
|
+
* `GATEWAY_ERROR` or a `SERVICE_UNAVAILABLE` into a generic 500 "would take away the one
|
|
75
|
+
* thing that tells a developer whether to retry."
|
|
76
|
+
*
|
|
77
|
+
* **That default is safe because of your call sites, not because of this code.** It holds
|
|
78
|
+
* only while every non-masked 5xx is handed a message somebody wrote for the client. A repo
|
|
79
|
+
* whose repository layer interpolates the driver's error into the message it raises — one
|
|
80
|
+
* donor's does, deliberately, so that duplicate-key heuristics keep working — wants
|
|
81
|
+
* `maskAll` instead.
|
|
82
|
+
*/
|
|
83
|
+
maskedCodes?: readonly Code[];
|
|
84
|
+
/** Replace every 5xx message, and let `expose` be what opts an authored sentence back in. */
|
|
85
|
+
maskAll?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Drop `details` from a 5xx whose message you did NOT mask — a separate knob because it is a
|
|
88
|
+
* separate decision. The newest donors put a readiness report in a 503's details, naming
|
|
89
|
+
* which dependency is down so a deploy gate and a human at 3am can both read it; another
|
|
90
|
+
* donor's details are where caught error text is recorded, and must never go out. Both are
|
|
91
|
+
* right about their own repo, which is why this is not folded into the mask above. (A masked
|
|
92
|
+
* 5xx drops its details on its own: the body is built fresh from `internal`.)
|
|
93
|
+
*
|
|
94
|
+
* The sharpest reason to turn it on is a caught driver error handed straight to `details`.
|
|
95
|
+
* On a CHECK or NOT NULL violation Postgres writes the ENTIRE failing row into its `detail`
|
|
96
|
+
* field — `Failing row contains (someone@example.com, 4242…)`, every column, values
|
|
97
|
+
* included. Measured against a real server, not assumed.
|
|
98
|
+
*/
|
|
99
|
+
maskDetails?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `retryAfterSecs` also rides inside `details`, because that is where clients already look —
|
|
104
|
+
* an explicit `null` included, since "waiting cannot fix this" is an answer a client needs and
|
|
105
|
+
* the only alternative is the hand-written code list this replaces.
|
|
106
|
+
*
|
|
107
|
+
* Only an object `details` can carry it. Spreading an ARRAY — a list of validation issues —
|
|
108
|
+
* turns it into `{"0": …}` and breaks every client that parses it; spreading a STRING turns it
|
|
109
|
+
* into one key per character. Anything that is not a plain object is handed back untouched, and
|
|
110
|
+
* the header still tells that caller when to come back.
|
|
111
|
+
*/
|
|
112
|
+
function detailsWithRetry(err: AppError): unknown {
|
|
113
|
+
if (err.retryAfterSecs === undefined) return err.details;
|
|
114
|
+
const carries =
|
|
115
|
+
err.details === undefined ||
|
|
116
|
+
(typeof err.details === "object" && err.details !== null && !Array.isArray(err.details));
|
|
117
|
+
if (!carries) return err.details;
|
|
118
|
+
return { ...err.details, retryAfterSecs: err.retryAfterSecs };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The wire body for an error somebody raised on purpose.
|
|
123
|
+
*
|
|
124
|
+
* It is a function rather than a method on `AppError`, and that is a fix rather than a style
|
|
125
|
+
* choice — see the note on the class. It also keeps the envelope in one file with the mask,
|
|
126
|
+
* instead of in two.
|
|
127
|
+
*/
|
|
128
|
+
function appErrorBody<Code extends string>(err: AppError<Code>): ApiError<Code> {
|
|
129
|
+
const details = detailsWithRetry(err);
|
|
130
|
+
return {
|
|
131
|
+
error: {
|
|
132
|
+
code: err.code,
|
|
133
|
+
message: err.message,
|
|
134
|
+
...(err.messageKey !== undefined && { messageKey: err.messageKey }),
|
|
135
|
+
...(err.params !== undefined && { params: err.params }),
|
|
136
|
+
...(details !== undefined && { details }),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function envelope<Code extends string, Key extends string>(
|
|
142
|
+
canned: CannedError<Code, Key>,
|
|
143
|
+
details?: unknown,
|
|
144
|
+
): ApiError<Code> {
|
|
145
|
+
return {
|
|
146
|
+
error: {
|
|
147
|
+
code: canned.code,
|
|
148
|
+
message: canned.message,
|
|
149
|
+
...(canned.messageKey !== undefined && { messageKey: canned.messageKey }),
|
|
150
|
+
...(details !== undefined && { details }),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface RawIssue {
|
|
156
|
+
path?: unknown;
|
|
157
|
+
code?: unknown;
|
|
158
|
+
maximum?: unknown;
|
|
159
|
+
minimum?: unknown;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Recognize a validation failure without importing the validator.
|
|
164
|
+
*
|
|
165
|
+
* `name === "ZodError"` plus an `issues` array holds for zod 3.25, 4.4 and 4.5 — measured, all
|
|
166
|
+
* three, because this package must not make an adopter's validator its own dependency. It also
|
|
167
|
+
* accepts an issue list that arrived some other way, which is what a second door (an MCP tool,
|
|
168
|
+
* a queue consumer) needs.
|
|
169
|
+
*/
|
|
170
|
+
function zodIssues(err: unknown): RawIssue[] | null {
|
|
171
|
+
if (typeof err !== "object" || err === null) return null;
|
|
172
|
+
const { name, issues } = err as { name?: unknown; issues?: unknown };
|
|
173
|
+
if (name !== "ZodError" || !Array.isArray(issues)) return null;
|
|
174
|
+
return issues as RawIssue[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The field path, the rule it failed, and — for a range — the BOUND it failed against.
|
|
179
|
+
*
|
|
180
|
+
* Never the rejected value, and never the schema's internals. All six donors carry a version of
|
|
181
|
+
* that comment; what none of them carries is the proof, so here it is: handing the validator's
|
|
182
|
+
* issues straight to the client ships back the caller's own key names (`keys`), the enum's
|
|
183
|
+
* allowed values (`values`), the validator's English sentence and the expected type
|
|
184
|
+
* (`origin`) — four disclosures from one convenience, and two repos in the fleet do it today.
|
|
185
|
+
* An audit note written against an older validator looks for `received`, which the current one
|
|
186
|
+
* no longer emits; the projection is an allow-list precisely so a rename cannot reopen this.
|
|
187
|
+
*
|
|
188
|
+
* The bound is the exception, and it belongs to the caller: it is the published contract, and
|
|
189
|
+
* a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
|
|
190
|
+
*/
|
|
191
|
+
function safeIssues(issues: RawIssue[]): unknown[] {
|
|
192
|
+
return issues.map((issue) => ({
|
|
193
|
+
path: issue.path,
|
|
194
|
+
code: issue.code,
|
|
195
|
+
...(typeof issue.maximum === "number" && { maximum: issue.maximum }),
|
|
196
|
+
...(typeof issue.minimum === "number" && { minimum: issue.minimum }),
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `AppError` is generic over the product's own code union, and no runtime check can verify
|
|
202
|
+
* membership. The predicate asserts what `createAppError` guarantees: every `AppError` in this
|
|
203
|
+
* app was built from the map whose keys are `Code`.
|
|
204
|
+
*/
|
|
205
|
+
function isAppError<Code extends string>(err: unknown): err is AppError<Code> {
|
|
206
|
+
return err instanceof AppError;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const DEFAULT_MASKED = ["INTERNAL_ERROR"];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Binds the mask policy and the two canned bodies, and returns the function that answers.
|
|
213
|
+
*
|
|
214
|
+
* Bound once, at the app's edge, because the alternative is what the reading found: four places
|
|
215
|
+
* in one fleet deciding the mask separately, and the one furthest from the API getting it
|
|
216
|
+
* wrong. Every other door — a tool wrapper, a worker's health port — imports the same bound
|
|
217
|
+
* function and cannot disagree with the API about what a refusal looks like.
|
|
218
|
+
*
|
|
219
|
+
* ```ts
|
|
220
|
+
* export const errorResponse = createErrorResponse<ErrorCode, MessageKey>({
|
|
221
|
+
* internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
|
|
222
|
+
* validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
|
|
223
|
+
* });
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
export function createErrorResponse<Code extends string = string, Key extends string = string>(
|
|
227
|
+
opts: ErrorResponseOptions<Code, Key>,
|
|
228
|
+
) {
|
|
229
|
+
const maskedCodes: readonly string[] = opts.maskedCodes ?? DEFAULT_MASKED;
|
|
230
|
+
|
|
231
|
+
return function errorResponse(err: unknown): ErrorAnswer<Code> {
|
|
232
|
+
const issues = zodIssues(err);
|
|
233
|
+
if (issues !== null) {
|
|
234
|
+
return {
|
|
235
|
+
status: 400,
|
|
236
|
+
body: envelope(opts.validation, safeIssues(issues)),
|
|
237
|
+
headers: {},
|
|
238
|
+
kind: "client",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (isAppError<Code>(err)) {
|
|
243
|
+
const headers: Record<string, string> = {};
|
|
244
|
+
// The standard header, not just our envelope: every HTTP client, proxy and SDK already
|
|
245
|
+
// knows how to wait on `Retry-After`, and none of them knows `details.retryAfterSecs`.
|
|
246
|
+
// A number only — a refusal that waiting cannot fix says so in the body, because
|
|
247
|
+
// `Retry-After: null` is a header that states a wait and names no time.
|
|
248
|
+
if (typeof err.retryAfterSecs === "number") {
|
|
249
|
+
headers["Retry-After"] = String(err.retryAfterSecs);
|
|
250
|
+
}
|
|
251
|
+
// RFC 6750 §3: a 401 names the scheme it wants. Without it a 401 is a closed door with no
|
|
252
|
+
// handle — which is what an agent, with no human to ask, is left holding.
|
|
253
|
+
if (err.statusCode === 401) headers["WWW-Authenticate"] = "Bearer";
|
|
254
|
+
|
|
255
|
+
if (err.statusCode < 500)
|
|
256
|
+
return { status: err.statusCode, body: appErrorBody(err), headers, kind: "client" };
|
|
257
|
+
|
|
258
|
+
const hide = !err.expose && (opts.maskAll === true || maskedCodes.includes(err.code));
|
|
259
|
+
if (hide) {
|
|
260
|
+
// The message is replaced; the STATUS is not. A status is chosen by our own map and
|
|
261
|
+
// discloses nothing, while it is the only thing left telling a client whether waiting
|
|
262
|
+
// can help — collapsing a masked 502 into a 500 throws that away for no gain. One
|
|
263
|
+
// donor does collapse it, and never noticed because it masks only the code that is
|
|
264
|
+
// already a 500.
|
|
265
|
+
return { status: err.statusCode, body: envelope(opts.internal), headers, kind: "server" };
|
|
266
|
+
}
|
|
267
|
+
const body = appErrorBody(err);
|
|
268
|
+
if (opts.maskDetails === true) delete body.error.details;
|
|
269
|
+
return { status: err.statusCode, body, headers, kind: "server" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Nothing raised this on purpose, so nothing in it was written for a reader. Whatever it
|
|
273
|
+
// says stays in the log: a background worker in the fleet answers `toMessage(err)` on its
|
|
274
|
+
// health port today, which is a driver's sentence on the wire.
|
|
275
|
+
return { status: 500, body: envelope(opts.internal), headers: {}, kind: "unexpected" };
|
|
276
|
+
};
|
|
277
|
+
}
|