@zuplo/errors 7.12.5 → 7.12.6
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/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/problem-parsing.d.ts +77 -0
- package/dist/problem-parsing.d.ts.map +1 -0
- package/dist/problem-parsing.js +113 -0
- package/dist/problem-parsing.js.map +1 -0
- package/dist/problem-parsing.spec.d.ts +2 -0
- package/dist/problem-parsing.spec.d.ts.map +1 -0
- package/dist/problem-parsing.spec.js +152 -0
- package/dist/problem-parsing.spec.js.map +1 -0
- package/package.json +3 -2
- package/src/errors.ts +12 -2
- package/src/index.ts +1 -0
- package/src/problem-parsing.spec.ts +220 -0
- package/src/problem-parsing.ts +130 -0
package/dist/errors.d.ts
CHANGED
|
@@ -2,6 +2,14 @@ import { ProblemDetails, ProblemDetailsFactory } from "./interfaces.js";
|
|
|
2
2
|
import * as Problems from "./problems.js";
|
|
3
3
|
export type { ProblemDetails, ProblemDetailsFactory };
|
|
4
4
|
export { Problems };
|
|
5
|
+
/**
|
|
6
|
+
* Every problem `type` an {@link ApiError} accepts must be a URI under this
|
|
7
|
+
* prefix. Exported so callers that validate an untrusted problem body before
|
|
8
|
+
* constructing an `ApiError` test the same rule the constructor enforces —
|
|
9
|
+
* checking a divergent prefix turns a rejected `type` into a thrown plain
|
|
10
|
+
* `Error` instead of the upstream problem.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PROBLEM_TYPE_PREFIX = "https://zup.fail/";
|
|
5
13
|
/**
|
|
6
14
|
* The default format for Api errors
|
|
7
15
|
*/
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAE1C,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAE1C,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,CAAC;AAEpB;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAC;AAEvD;;GAEG;AACH,qBAAa,QAAS,SAAQ,KAAK;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAE3B,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,YAAY;CAiB5D;AAED;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,QAAQ;gBAChC,OAAO,GAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,UAAU,CAAM;CAOtE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,cAAc,CAYpE"}
|
package/dist/errors.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import * as Problems from "./problems.js";
|
|
2
2
|
export { Problems };
|
|
3
|
+
/**
|
|
4
|
+
* Every problem `type` an {@link ApiError} accepts must be a URI under this
|
|
5
|
+
* prefix. Exported so callers that validate an untrusted problem body before
|
|
6
|
+
* constructing an `ApiError` test the same rule the constructor enforces —
|
|
7
|
+
* checking a divergent prefix turns a rejected `type` into a thrown plain
|
|
8
|
+
* `Error` instead of the upstream problem.
|
|
9
|
+
*/
|
|
10
|
+
export const PROBLEM_TYPE_PREFIX = "https://zup.fail/";
|
|
3
11
|
/**
|
|
4
12
|
* The default format for Api errors
|
|
5
13
|
*/
|
|
@@ -11,8 +19,8 @@ export class ApiError extends Error {
|
|
|
11
19
|
extra;
|
|
12
20
|
constructor(problem, options) {
|
|
13
21
|
super(problem.title, options);
|
|
14
|
-
if (!problem.type.startsWith(
|
|
15
|
-
throw new Error(`Invalid 'type' specified on ApiError, must be a URI starting with '
|
|
22
|
+
if (!problem.type.startsWith(PROBLEM_TYPE_PREFIX)) {
|
|
23
|
+
throw new Error(`Invalid 'type' specified on ApiError, must be a URI starting with '${PROBLEM_TYPE_PREFIX}'. Type was set to ${problem.type}`);
|
|
16
24
|
}
|
|
17
25
|
const { type, status, detail, instance, ...extra } = problem;
|
|
18
26
|
this.name = "ApiProblemError";
|
package/dist/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAG1C,OAAO,EAAE,QAAQ,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAG1C,OAAO,EAAE,QAAQ,EAAE,CAAC;AAEpB;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,mBAAmB,CAAC;AAEvD;;GAEG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IAC1B,IAAI,CAAS;IACb,MAAM,CAAS;IACf,MAAM,CAAU;IAChB,QAAQ,CAAU;IAClB,KAAK,CAA2B;IAEvC,YAAY,OAAuB,EAAE,OAAsB;QACzD,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QAE9B,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,mBAAmB,CAAC,EAAE,CAAC;YAClD,MAAM,IAAI,KAAK,CACb,sEAAsE,mBAAmB,sBAAsB,OAAO,CAAC,IAAI,EAAE,CAC9H,CAAC;QACJ,CAAC;QAED,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAG,OAAO,CAAC;QAC7D,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;CACF;AAED;;GAEG;AACH,MAAM,OAAO,gBAAiB,SAAQ,QAAQ;IAC5C,YAAY,UAAuD,EAAE;QACnE,KAAK,CAAC;YACJ,GAAG,OAAO;YACV,GAAG,QAAQ,CAAC,SAAS;SACtB,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,OAAO,CACL,GAAG,KAAK,SAAS;QACjB,GAAG,KAAK,IAAI;QACZ,OAAO,GAAG,KAAK,QAAQ;QACvB,MAAM,IAAI,GAAG;QACb,OAAO,IAAI,GAAG;QACd,QAAQ,IAAI,GAAG;QACf,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ;QAC5B,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ;QAC7B,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,CAC/B,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,YAAY,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,YAAY,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,cAAc,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,cAAc,sBAAsB,CAAC"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ProblemDetails } from "./interfaces.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether an untrusted value can be rethrown as an {@link ApiError} that
|
|
4
|
+
* faithfully represents the upstream failure.
|
|
5
|
+
*
|
|
6
|
+
* Every internal service client in this repo gets a non-2xx response from
|
|
7
|
+
* another Zuplo service and wants to rethrow its problem+json rather than
|
|
8
|
+
* flatten it into a generic 500. The naive form of that check —
|
|
9
|
+
* `if (body.type && body.status && body.title)` — is wrong in four ways, and
|
|
10
|
+
* each one turned a diagnosable upstream failure into an opaque one:
|
|
11
|
+
*
|
|
12
|
+
* - **Not an object.** `null` is valid JSON, and reading `.type` off it throws
|
|
13
|
+
* a `TypeError` out of the client. An array passes a `typeof === "object"`
|
|
14
|
+
* guard and then fails later.
|
|
15
|
+
* - **Wrong member types.** A `status` of `"400"` is truthy, so it reached
|
|
16
|
+
* `ApiError` as a string.
|
|
17
|
+
* - **A `type` outside {@link PROBLEM_TYPE_PREFIX}.** `ApiError`'s constructor
|
|
18
|
+
* rejects those, so passing one through throws a plain `Error` about the
|
|
19
|
+
* type instead of surfacing the problem. RFC 7807's default `about:blank`
|
|
20
|
+
* lands here.
|
|
21
|
+
* - **A `status` that disagrees with the response, or that is not an error
|
|
22
|
+
* status at all.** This is the dangerous one. `handleError` copies
|
|
23
|
+
* `ApiError.status` straight into `reply.status(...)` (`@zuplo/server`'s
|
|
24
|
+
* `fastify/error-handler.ts`), so the body dictates the response status.
|
|
25
|
+
* Two guards are needed, not one:
|
|
26
|
+
* - It must equal `httpStatus`. Otherwise a body claiming `200` serves an
|
|
27
|
+
* upstream failure as a success. RFC 7807 says `status` SHOULD match the
|
|
28
|
+
* response, so equality costs nothing on a conforming body.
|
|
29
|
+
* - It must be a 4xx or 5xx. Equality alone is not enough, because callers
|
|
30
|
+
* do not all gate on `>= 400`: `ApiKeyService` treats every
|
|
31
|
+
* `status >= 300` as a failure and `Response.ok` is false for 3xx too. A
|
|
32
|
+
* `301` response carrying a body that also says `301` would satisfy
|
|
33
|
+
* equality and turn into `reply.status(301)` — an upstream-dictated
|
|
34
|
+
* redirect emitted from an error path.
|
|
35
|
+
* - **Non-string `detail` or `instance`.** Both are declared `string`, and
|
|
36
|
+
* `handleError` copies them into the response body while
|
|
37
|
+
* `request.audit.setError` puts `detail` into the audit event. Left
|
|
38
|
+
* unchecked, an object or array from an untrusted body reaches both while
|
|
39
|
+
* the types claim it is a string.
|
|
40
|
+
*
|
|
41
|
+
* `httpStatus` is the status the upstream service actually responded with.
|
|
42
|
+
*
|
|
43
|
+
* Extra members are preserved: RFC 7807 allows them and callers pass them
|
|
44
|
+
* through to `ApiError.extra`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isRethrowableProblem(value: unknown, httpStatus: number): value is ProblemDetails;
|
|
47
|
+
/**
|
|
48
|
+
* Parses an upstream error body and returns its problem detail, or `undefined`
|
|
49
|
+
* when the body is not one {@link isRethrowableProblem} accepts.
|
|
50
|
+
*
|
|
51
|
+
* Takes the body as text rather than a parsed value because a failing internal
|
|
52
|
+
* service is not guaranteed to answer with JSON at all: an HTML error page
|
|
53
|
+
* from a load balancer, or an empty body on a Cloud Run cold-start timeout,
|
|
54
|
+
* makes `Response.json()` throw a `SyntaxError` that escapes the caller as an
|
|
55
|
+
* opaque 500 — and skips the log that would have recorded what the upstream
|
|
56
|
+
* actually returned. Read `res.text()` and hand it here instead.
|
|
57
|
+
*
|
|
58
|
+
* Callers that already hold a parsed body (an SDK that deserializes for them)
|
|
59
|
+
* should use {@link isRethrowableProblem} directly rather than re-serializing.
|
|
60
|
+
*/
|
|
61
|
+
export declare function parseProblemDetails(rawBody: string, httpStatus: number): ProblemDetails | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Caps how much of an unparsed upstream error body reaches the logs. A proxy
|
|
64
|
+
* error page is measured in kilobytes and the first few lines are enough to
|
|
65
|
+
* tell an HTML 502 from a truncated JSON payload.
|
|
66
|
+
*/
|
|
67
|
+
export declare const MAX_LOGGED_PROBLEM_BODY_LENGTH = 512;
|
|
68
|
+
/**
|
|
69
|
+
* Trims an upstream error body to {@link MAX_LOGGED_PROBLEM_BODY_LENGTH} for
|
|
70
|
+
* logging, marking it when anything was dropped.
|
|
71
|
+
*
|
|
72
|
+
* Log the body whenever {@link parseProblemDetails} returns `undefined`:
|
|
73
|
+
* that is the branch where the upstream failure gets flattened into a generic
|
|
74
|
+
* 500, so the body is the only remaining evidence of what actually happened.
|
|
75
|
+
*/
|
|
76
|
+
export declare function truncateProblemBody(body: string): string;
|
|
77
|
+
//# sourceMappingURL=problem-parsing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem-parsing.d.ts","sourceRoot":"","sources":["../src/problem-parsing.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,OAAO,EACd,UAAU,EAAE,MAAM,GACjB,KAAK,IAAI,cAAc,CAezB;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB,cAAc,GAAG,SAAS,CAS5B;AAED;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,MAAM,CAAC;AAElD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIxD"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { PROBLEM_TYPE_PREFIX } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether an untrusted value can be rethrown as an {@link ApiError} that
|
|
4
|
+
* faithfully represents the upstream failure.
|
|
5
|
+
*
|
|
6
|
+
* Every internal service client in this repo gets a non-2xx response from
|
|
7
|
+
* another Zuplo service and wants to rethrow its problem+json rather than
|
|
8
|
+
* flatten it into a generic 500. The naive form of that check —
|
|
9
|
+
* `if (body.type && body.status && body.title)` — is wrong in four ways, and
|
|
10
|
+
* each one turned a diagnosable upstream failure into an opaque one:
|
|
11
|
+
*
|
|
12
|
+
* - **Not an object.** `null` is valid JSON, and reading `.type` off it throws
|
|
13
|
+
* a `TypeError` out of the client. An array passes a `typeof === "object"`
|
|
14
|
+
* guard and then fails later.
|
|
15
|
+
* - **Wrong member types.** A `status` of `"400"` is truthy, so it reached
|
|
16
|
+
* `ApiError` as a string.
|
|
17
|
+
* - **A `type` outside {@link PROBLEM_TYPE_PREFIX}.** `ApiError`'s constructor
|
|
18
|
+
* rejects those, so passing one through throws a plain `Error` about the
|
|
19
|
+
* type instead of surfacing the problem. RFC 7807's default `about:blank`
|
|
20
|
+
* lands here.
|
|
21
|
+
* - **A `status` that disagrees with the response, or that is not an error
|
|
22
|
+
* status at all.** This is the dangerous one. `handleError` copies
|
|
23
|
+
* `ApiError.status` straight into `reply.status(...)` (`@zuplo/server`'s
|
|
24
|
+
* `fastify/error-handler.ts`), so the body dictates the response status.
|
|
25
|
+
* Two guards are needed, not one:
|
|
26
|
+
* - It must equal `httpStatus`. Otherwise a body claiming `200` serves an
|
|
27
|
+
* upstream failure as a success. RFC 7807 says `status` SHOULD match the
|
|
28
|
+
* response, so equality costs nothing on a conforming body.
|
|
29
|
+
* - It must be a 4xx or 5xx. Equality alone is not enough, because callers
|
|
30
|
+
* do not all gate on `>= 400`: `ApiKeyService` treats every
|
|
31
|
+
* `status >= 300` as a failure and `Response.ok` is false for 3xx too. A
|
|
32
|
+
* `301` response carrying a body that also says `301` would satisfy
|
|
33
|
+
* equality and turn into `reply.status(301)` — an upstream-dictated
|
|
34
|
+
* redirect emitted from an error path.
|
|
35
|
+
* - **Non-string `detail` or `instance`.** Both are declared `string`, and
|
|
36
|
+
* `handleError` copies them into the response body while
|
|
37
|
+
* `request.audit.setError` puts `detail` into the audit event. Left
|
|
38
|
+
* unchecked, an object or array from an untrusted body reaches both while
|
|
39
|
+
* the types claim it is a string.
|
|
40
|
+
*
|
|
41
|
+
* `httpStatus` is the status the upstream service actually responded with.
|
|
42
|
+
*
|
|
43
|
+
* Extra members are preserved: RFC 7807 allows them and callers pass them
|
|
44
|
+
* through to `ApiError.extra`.
|
|
45
|
+
*/
|
|
46
|
+
export function isRethrowableProblem(value, httpStatus) {
|
|
47
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const problem = value;
|
|
51
|
+
return (typeof problem.type === "string" &&
|
|
52
|
+
typeof problem.title === "string" &&
|
|
53
|
+
isErrorStatus(problem.status) &&
|
|
54
|
+
problem.status === httpStatus &&
|
|
55
|
+
problem.type.startsWith(PROBLEM_TYPE_PREFIX) &&
|
|
56
|
+
isAbsentOrString(problem.detail) &&
|
|
57
|
+
isAbsentOrString(problem.instance));
|
|
58
|
+
}
|
|
59
|
+
/** Whether a value is an integer HTTP status in the 4xx or 5xx range. */
|
|
60
|
+
function isErrorStatus(value) {
|
|
61
|
+
return (typeof value === "number" &&
|
|
62
|
+
Number.isInteger(value) &&
|
|
63
|
+
value >= 400 &&
|
|
64
|
+
value <= 599);
|
|
65
|
+
}
|
|
66
|
+
/** Whether an optional problem member is either absent or a string. */
|
|
67
|
+
function isAbsentOrString(value) {
|
|
68
|
+
return value === undefined || typeof value === "string";
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parses an upstream error body and returns its problem detail, or `undefined`
|
|
72
|
+
* when the body is not one {@link isRethrowableProblem} accepts.
|
|
73
|
+
*
|
|
74
|
+
* Takes the body as text rather than a parsed value because a failing internal
|
|
75
|
+
* service is not guaranteed to answer with JSON at all: an HTML error page
|
|
76
|
+
* from a load balancer, or an empty body on a Cloud Run cold-start timeout,
|
|
77
|
+
* makes `Response.json()` throw a `SyntaxError` that escapes the caller as an
|
|
78
|
+
* opaque 500 — and skips the log that would have recorded what the upstream
|
|
79
|
+
* actually returned. Read `res.text()` and hand it here instead.
|
|
80
|
+
*
|
|
81
|
+
* Callers that already hold a parsed body (an SDK that deserializes for them)
|
|
82
|
+
* should use {@link isRethrowableProblem} directly rather than re-serializing.
|
|
83
|
+
*/
|
|
84
|
+
export function parseProblemDetails(rawBody, httpStatus) {
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(rawBody);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return isRethrowableProblem(parsed, httpStatus) ? parsed : undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Caps how much of an unparsed upstream error body reaches the logs. A proxy
|
|
96
|
+
* error page is measured in kilobytes and the first few lines are enough to
|
|
97
|
+
* tell an HTML 502 from a truncated JSON payload.
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_LOGGED_PROBLEM_BODY_LENGTH = 512;
|
|
100
|
+
/**
|
|
101
|
+
* Trims an upstream error body to {@link MAX_LOGGED_PROBLEM_BODY_LENGTH} for
|
|
102
|
+
* logging, marking it when anything was dropped.
|
|
103
|
+
*
|
|
104
|
+
* Log the body whenever {@link parseProblemDetails} returns `undefined`:
|
|
105
|
+
* that is the branch where the upstream failure gets flattened into a generic
|
|
106
|
+
* 500, so the body is the only remaining evidence of what actually happened.
|
|
107
|
+
*/
|
|
108
|
+
export function truncateProblemBody(body) {
|
|
109
|
+
return body.length > MAX_LOGGED_PROBLEM_BODY_LENGTH
|
|
110
|
+
? `${body.slice(0, MAX_LOGGED_PROBLEM_BODY_LENGTH)}...[truncated]`
|
|
111
|
+
: body;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=problem-parsing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem-parsing.js","sourceRoot":"","sources":["../src/problem-parsing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAGlD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,UAAU,oBAAoB,CAClC,KAAc,EACd,UAAkB;IAElB,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACxE,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,OAAO,GAAG,KAAgC,CAAC;IACjD,OAAO,CACL,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ;QAChC,OAAO,OAAO,CAAC,KAAK,KAAK,QAAQ;QACjC,aAAa,CAAC,OAAO,CAAC,MAAM,CAAC;QAC7B,OAAO,CAAC,MAAM,KAAK,UAAU;QAC7B,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,mBAAmB,CAAC;QAC5C,gBAAgB,CAAC,OAAO,CAAC,MAAM,CAAC;QAChC,gBAAgB,CAAC,OAAO,CAAC,QAAQ,CAAC,CACnC,CAAC;AACJ,CAAC;AAED,yEAAyE;AACzE,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC;QACvB,KAAK,IAAI,GAAG;QACZ,KAAK,IAAI,GAAG,CACb,CAAC;AACJ,CAAC;AAED,uEAAuE;AACvE,SAAS,gBAAgB,CAAC,KAAc;IACtC,OAAO,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC;AAC1D,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,mBAAmB,CACjC,OAAe,EACf,UAAkB;IAElB,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,OAAO,oBAAoB,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;AACvE,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,GAAG,CAAC;AAElD;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,CAAC,MAAM,GAAG,8BAA8B;QACjD,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,8BAA8B,CAAC,gBAAgB;QAClE,CAAC,CAAC,IAAI,CAAC;AACX,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem-parsing.spec.d.ts","sourceRoot":"","sources":["../src/problem-parsing.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { assert } from "chai";
|
|
2
|
+
import { ApiError, PROBLEM_TYPE_PREFIX } from "./errors.js";
|
|
3
|
+
import { isRethrowableProblem, parseProblemDetails, } from "./problem-parsing.js";
|
|
4
|
+
const problem = {
|
|
5
|
+
type: `${PROBLEM_TYPE_PREFIX}auth/client-not-found`,
|
|
6
|
+
title: "Client not found",
|
|
7
|
+
status: 400,
|
|
8
|
+
detail: "No client matches that id",
|
|
9
|
+
};
|
|
10
|
+
describe("isRethrowableProblem", function () {
|
|
11
|
+
it("accepts a problem whose type and status line up", function () {
|
|
12
|
+
assert.isTrue(isRethrowableProblem(problem, 400));
|
|
13
|
+
});
|
|
14
|
+
it("rejects values that are not objects", function () {
|
|
15
|
+
// `null` parses out of valid JSON, and reading `.type` off it used to
|
|
16
|
+
// throw a TypeError out of the calling service client.
|
|
17
|
+
for (const value of [null, undefined, "a string", 42, true]) {
|
|
18
|
+
assert.isFalse(isRethrowableProblem(value, 400));
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it("rejects an array", function () {
|
|
22
|
+
// Arrays are objects, so a null/typeof guard alone lets them through.
|
|
23
|
+
assert.isFalse(isRethrowableProblem([], 400));
|
|
24
|
+
assert.isFalse(isRethrowableProblem([problem], 400));
|
|
25
|
+
});
|
|
26
|
+
it("rejects a missing member", function () {
|
|
27
|
+
for (const member of ["type", "title", "status"]) {
|
|
28
|
+
const { [member]: _dropped, ...without } = problem;
|
|
29
|
+
assert.isFalse(isRethrowableProblem(without, 400), `expected a problem without '${member}' to be rejected`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
it("rejects members of the wrong type", function () {
|
|
33
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: "400" }, 400));
|
|
34
|
+
assert.isFalse(isRethrowableProblem({ ...problem, type: 1 }, 400));
|
|
35
|
+
assert.isFalse(isRethrowableProblem({ ...problem, title: null }, 400));
|
|
36
|
+
});
|
|
37
|
+
it("rejects a type outside the zup.fail prefix", function () {
|
|
38
|
+
// RFC 7807's default. `ApiError` rejects it, so rethrowing would throw a
|
|
39
|
+
// plain Error about the type rather than surfacing the upstream problem.
|
|
40
|
+
assert.isFalse(isRethrowableProblem({ ...problem, type: "about:blank" }, 400));
|
|
41
|
+
assert.isFalse(isRethrowableProblem({ ...problem, type: "https://example.com/errors/oops" }, 400));
|
|
42
|
+
});
|
|
43
|
+
describe("the status must match the response", function () {
|
|
44
|
+
// `ApiError.status` is echoed into `reply.status(...)`, so an unchecked
|
|
45
|
+
// body status is a response-status injection.
|
|
46
|
+
it("rejects a body claiming success on a failed response", function () {
|
|
47
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 500));
|
|
48
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 204 }, 500));
|
|
49
|
+
});
|
|
50
|
+
it("rejects a body claiming zero", function () {
|
|
51
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 0 }, 500));
|
|
52
|
+
});
|
|
53
|
+
it("rejects a body claiming a redirect", function () {
|
|
54
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 301 }, 500));
|
|
55
|
+
});
|
|
56
|
+
it("rejects a body that merely disagrees", function () {
|
|
57
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 403 }, 400));
|
|
58
|
+
});
|
|
59
|
+
it("accepts a matching 5xx", function () {
|
|
60
|
+
const upstream = { ...problem, status: 503 };
|
|
61
|
+
assert.isTrue(isRethrowableProblem(upstream, 503));
|
|
62
|
+
});
|
|
63
|
+
it("rejects a redirect even when the response agrees", function () {
|
|
64
|
+
// Equality alone is not enough. `ApiKeyService` treats every
|
|
65
|
+
// `status >= 300` as a failure and `Response.ok` is false for 3xx, so a
|
|
66
|
+
// 301 body on a 301 response would otherwise become `reply.status(301)`
|
|
67
|
+
// — an upstream-dictated redirect emitted from an error path.
|
|
68
|
+
for (const status of [301, 302, 304, 307, 308]) {
|
|
69
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status }, status), `expected a matching ${status} to be rejected`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it("rejects a matching 2xx", function () {
|
|
73
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 200));
|
|
74
|
+
});
|
|
75
|
+
it("rejects a status outside the HTTP range", function () {
|
|
76
|
+
for (const status of [399, 600, -400, 4000]) {
|
|
77
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
it("rejects a non-integer status", function () {
|
|
81
|
+
for (const status of [404.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
82
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
it("accepts the 4xx and 5xx boundaries", function () {
|
|
86
|
+
for (const status of [400, 499, 500, 599]) {
|
|
87
|
+
assert.isTrue(isRethrowableProblem({ ...problem, status }, status), `expected a matching ${status} to be accepted`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("optional standard members", function () {
|
|
92
|
+
// `handleError` copies `detail` and `instance` into the response body,
|
|
93
|
+
// and `request.audit.setError` puts `detail` into the audit event. Both
|
|
94
|
+
// are declared `string`, so a non-string reaches those while the types
|
|
95
|
+
// claim otherwise.
|
|
96
|
+
it("rejects a non-string detail", function () {
|
|
97
|
+
for (const detail of [{ nested: true }, ["a"], 42, null]) {
|
|
98
|
+
assert.isFalse(isRethrowableProblem({ ...problem, detail }, 400));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
it("rejects a non-string instance", function () {
|
|
102
|
+
for (const instance of [{ nested: true }, ["a"], 42, null]) {
|
|
103
|
+
assert.isFalse(isRethrowableProblem({ ...problem, instance }, 400));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
it("accepts them when absent", function () {
|
|
107
|
+
const { detail: _detail, ...withoutDetail } = problem;
|
|
108
|
+
assert.isTrue(isRethrowableProblem(withoutDetail, 400));
|
|
109
|
+
});
|
|
110
|
+
it("accepts a string instance", function () {
|
|
111
|
+
assert.isTrue(isRethrowableProblem({ ...problem, instance: "/clients/abc" }, 400));
|
|
112
|
+
});
|
|
113
|
+
it("still allows non-string extra members", function () {
|
|
114
|
+
// Only the standard members are typed. RFC 7807 extensions are free-form
|
|
115
|
+
// and callers pass them through to `ApiError.extra`.
|
|
116
|
+
assert.isTrue(isRethrowableProblem({ ...problem, errors: [{ field: "id" }] }, 400));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
it("accepts everything the ApiError constructor accepts", function () {
|
|
120
|
+
// Guards against this check drifting from the constructor's own rule.
|
|
121
|
+
assert.isTrue(isRethrowableProblem(problem, 400));
|
|
122
|
+
assert.doesNotThrow(() => new ApiError(problem));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe("parseProblemDetails", function () {
|
|
126
|
+
it("returns the problem from a conforming body", function () {
|
|
127
|
+
assert.deepEqual(parseProblemDetails(JSON.stringify(problem), 400), problem);
|
|
128
|
+
});
|
|
129
|
+
it("preserves extra members", function () {
|
|
130
|
+
// RFC 7807 allows them and callers pass them to ApiError.extra.
|
|
131
|
+
const withExtra = { ...problem, clientId: "abc123" };
|
|
132
|
+
assert.deepEqual(parseProblemDetails(JSON.stringify(withExtra), 400), withExtra);
|
|
133
|
+
});
|
|
134
|
+
it("returns undefined for an HTML error page", function () {
|
|
135
|
+
// What a load balancer or ingress returns on a 502.
|
|
136
|
+
assert.isUndefined(parseProblemDetails("<html><head><title>502 Bad Gateway</title></head></html>", 502));
|
|
137
|
+
});
|
|
138
|
+
it("returns undefined for an empty body", function () {
|
|
139
|
+
// What a Cloud Run cold-start timeout leaves behind.
|
|
140
|
+
assert.isUndefined(parseProblemDetails("", 504));
|
|
141
|
+
});
|
|
142
|
+
it("returns undefined for truncated JSON", function () {
|
|
143
|
+
assert.isUndefined(parseProblemDetails('{"type":"https://zup.fail/a"', 500));
|
|
144
|
+
});
|
|
145
|
+
it("returns undefined for a JSON null body", function () {
|
|
146
|
+
assert.isUndefined(parseProblemDetails("null", 500));
|
|
147
|
+
});
|
|
148
|
+
it("returns undefined for a body the guard rejects", function () {
|
|
149
|
+
assert.isUndefined(parseProblemDetails(JSON.stringify({ ...problem, status: 200 }), 500));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
//# sourceMappingURL=problem-parsing.spec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem-parsing.spec.js","sourceRoot":"","sources":["../src/problem-parsing.spec.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@zuplo/errors",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "7.12.
|
|
5
|
+
"version": "7.12.6",
|
|
6
6
|
"description": "Error types",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"author": "Zuplo",
|
|
22
22
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
23
23
|
"scripts": {
|
|
24
|
-
"build": "tsc --build"
|
|
24
|
+
"build": "tsc --build",
|
|
25
|
+
"test": "mocha"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"@types/chai": "^5.0.0",
|
package/src/errors.ts
CHANGED
|
@@ -3,6 +3,16 @@ import * as Problems from "./problems.js";
|
|
|
3
3
|
|
|
4
4
|
export type { ProblemDetails, ProblemDetailsFactory };
|
|
5
5
|
export { Problems };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every problem `type` an {@link ApiError} accepts must be a URI under this
|
|
9
|
+
* prefix. Exported so callers that validate an untrusted problem body before
|
|
10
|
+
* constructing an `ApiError` test the same rule the constructor enforces —
|
|
11
|
+
* checking a divergent prefix turns a rejected `type` into a thrown plain
|
|
12
|
+
* `Error` instead of the upstream problem.
|
|
13
|
+
*/
|
|
14
|
+
export const PROBLEM_TYPE_PREFIX = "https://zup.fail/";
|
|
15
|
+
|
|
6
16
|
/**
|
|
7
17
|
* The default format for Api errors
|
|
8
18
|
*/
|
|
@@ -16,9 +26,9 @@ export class ApiError extends Error {
|
|
|
16
26
|
constructor(problem: ProblemDetails, options?: ErrorOptions) {
|
|
17
27
|
super(problem.title, options);
|
|
18
28
|
|
|
19
|
-
if (!problem.type.startsWith(
|
|
29
|
+
if (!problem.type.startsWith(PROBLEM_TYPE_PREFIX)) {
|
|
20
30
|
throw new Error(
|
|
21
|
-
`Invalid 'type' specified on ApiError, must be a URI starting with '
|
|
31
|
+
`Invalid 'type' specified on ApiError, must be a URI starting with '${PROBLEM_TYPE_PREFIX}'. Type was set to ${problem.type}`
|
|
22
32
|
);
|
|
23
33
|
}
|
|
24
34
|
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { assert } from "chai";
|
|
2
|
+
import { ApiError, PROBLEM_TYPE_PREFIX } from "./errors.js";
|
|
3
|
+
import {
|
|
4
|
+
isRethrowableProblem,
|
|
5
|
+
parseProblemDetails,
|
|
6
|
+
} from "./problem-parsing.js";
|
|
7
|
+
|
|
8
|
+
const problem = {
|
|
9
|
+
type: `${PROBLEM_TYPE_PREFIX}auth/client-not-found`,
|
|
10
|
+
title: "Client not found",
|
|
11
|
+
status: 400,
|
|
12
|
+
detail: "No client matches that id",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("isRethrowableProblem", function () {
|
|
16
|
+
it("accepts a problem whose type and status line up", function () {
|
|
17
|
+
assert.isTrue(isRethrowableProblem(problem, 400));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects values that are not objects", function () {
|
|
21
|
+
// `null` parses out of valid JSON, and reading `.type` off it used to
|
|
22
|
+
// throw a TypeError out of the calling service client.
|
|
23
|
+
for (const value of [null, undefined, "a string", 42, true]) {
|
|
24
|
+
assert.isFalse(isRethrowableProblem(value, 400));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("rejects an array", function () {
|
|
29
|
+
// Arrays are objects, so a null/typeof guard alone lets them through.
|
|
30
|
+
assert.isFalse(isRethrowableProblem([], 400));
|
|
31
|
+
assert.isFalse(isRethrowableProblem([problem], 400));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects a missing member", function () {
|
|
35
|
+
for (const member of ["type", "title", "status"] as const) {
|
|
36
|
+
const { [member]: _dropped, ...without } = problem;
|
|
37
|
+
assert.isFalse(
|
|
38
|
+
isRethrowableProblem(without, 400),
|
|
39
|
+
`expected a problem without '${member}' to be rejected`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects members of the wrong type", function () {
|
|
45
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: "400" }, 400));
|
|
46
|
+
assert.isFalse(isRethrowableProblem({ ...problem, type: 1 }, 400));
|
|
47
|
+
assert.isFalse(isRethrowableProblem({ ...problem, title: null }, 400));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects a type outside the zup.fail prefix", function () {
|
|
51
|
+
// RFC 7807's default. `ApiError` rejects it, so rethrowing would throw a
|
|
52
|
+
// plain Error about the type rather than surfacing the upstream problem.
|
|
53
|
+
assert.isFalse(
|
|
54
|
+
isRethrowableProblem({ ...problem, type: "about:blank" }, 400)
|
|
55
|
+
);
|
|
56
|
+
assert.isFalse(
|
|
57
|
+
isRethrowableProblem(
|
|
58
|
+
{ ...problem, type: "https://example.com/errors/oops" },
|
|
59
|
+
400
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("the status must match the response", function () {
|
|
65
|
+
// `ApiError.status` is echoed into `reply.status(...)`, so an unchecked
|
|
66
|
+
// body status is a response-status injection.
|
|
67
|
+
it("rejects a body claiming success on a failed response", function () {
|
|
68
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 500));
|
|
69
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 204 }, 500));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects a body claiming zero", function () {
|
|
73
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 0 }, 500));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects a body claiming a redirect", function () {
|
|
77
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 301 }, 500));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects a body that merely disagrees", function () {
|
|
81
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 403 }, 400));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("accepts a matching 5xx", function () {
|
|
85
|
+
const upstream = { ...problem, status: 503 };
|
|
86
|
+
assert.isTrue(isRethrowableProblem(upstream, 503));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects a redirect even when the response agrees", function () {
|
|
90
|
+
// Equality alone is not enough. `ApiKeyService` treats every
|
|
91
|
+
// `status >= 300` as a failure and `Response.ok` is false for 3xx, so a
|
|
92
|
+
// 301 body on a 301 response would otherwise become `reply.status(301)`
|
|
93
|
+
// — an upstream-dictated redirect emitted from an error path.
|
|
94
|
+
for (const status of [301, 302, 304, 307, 308]) {
|
|
95
|
+
assert.isFalse(
|
|
96
|
+
isRethrowableProblem({ ...problem, status }, status),
|
|
97
|
+
`expected a matching ${status} to be rejected`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rejects a matching 2xx", function () {
|
|
103
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 200));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects a status outside the HTTP range", function () {
|
|
107
|
+
for (const status of [399, 600, -400, 4000]) {
|
|
108
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("rejects a non-integer status", function () {
|
|
113
|
+
for (const status of [404.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
114
|
+
assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("accepts the 4xx and 5xx boundaries", function () {
|
|
119
|
+
for (const status of [400, 499, 500, 599]) {
|
|
120
|
+
assert.isTrue(
|
|
121
|
+
isRethrowableProblem({ ...problem, status }, status),
|
|
122
|
+
`expected a matching ${status} to be accepted`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("optional standard members", function () {
|
|
129
|
+
// `handleError` copies `detail` and `instance` into the response body,
|
|
130
|
+
// and `request.audit.setError` puts `detail` into the audit event. Both
|
|
131
|
+
// are declared `string`, so a non-string reaches those while the types
|
|
132
|
+
// claim otherwise.
|
|
133
|
+
it("rejects a non-string detail", function () {
|
|
134
|
+
for (const detail of [{ nested: true }, ["a"], 42, null]) {
|
|
135
|
+
assert.isFalse(isRethrowableProblem({ ...problem, detail }, 400));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects a non-string instance", function () {
|
|
140
|
+
for (const instance of [{ nested: true }, ["a"], 42, null]) {
|
|
141
|
+
assert.isFalse(isRethrowableProblem({ ...problem, instance }, 400));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("accepts them when absent", function () {
|
|
146
|
+
const { detail: _detail, ...withoutDetail } = problem;
|
|
147
|
+
assert.isTrue(isRethrowableProblem(withoutDetail, 400));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("accepts a string instance", function () {
|
|
151
|
+
assert.isTrue(
|
|
152
|
+
isRethrowableProblem({ ...problem, instance: "/clients/abc" }, 400)
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("still allows non-string extra members", function () {
|
|
157
|
+
// Only the standard members are typed. RFC 7807 extensions are free-form
|
|
158
|
+
// and callers pass them through to `ApiError.extra`.
|
|
159
|
+
assert.isTrue(
|
|
160
|
+
isRethrowableProblem({ ...problem, errors: [{ field: "id" }] }, 400)
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("accepts everything the ApiError constructor accepts", function () {
|
|
166
|
+
// Guards against this check drifting from the constructor's own rule.
|
|
167
|
+
assert.isTrue(isRethrowableProblem(problem, 400));
|
|
168
|
+
assert.doesNotThrow(() => new ApiError(problem));
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("parseProblemDetails", function () {
|
|
173
|
+
it("returns the problem from a conforming body", function () {
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
parseProblemDetails(JSON.stringify(problem), 400),
|
|
176
|
+
problem
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("preserves extra members", function () {
|
|
181
|
+
// RFC 7807 allows them and callers pass them to ApiError.extra.
|
|
182
|
+
const withExtra = { ...problem, clientId: "abc123" };
|
|
183
|
+
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
parseProblemDetails(JSON.stringify(withExtra), 400),
|
|
186
|
+
withExtra
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns undefined for an HTML error page", function () {
|
|
191
|
+
// What a load balancer or ingress returns on a 502.
|
|
192
|
+
assert.isUndefined(
|
|
193
|
+
parseProblemDetails(
|
|
194
|
+
"<html><head><title>502 Bad Gateway</title></head></html>",
|
|
195
|
+
502
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns undefined for an empty body", function () {
|
|
201
|
+
// What a Cloud Run cold-start timeout leaves behind.
|
|
202
|
+
assert.isUndefined(parseProblemDetails("", 504));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("returns undefined for truncated JSON", function () {
|
|
206
|
+
assert.isUndefined(
|
|
207
|
+
parseProblemDetails('{"type":"https://zup.fail/a"', 500)
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("returns undefined for a JSON null body", function () {
|
|
212
|
+
assert.isUndefined(parseProblemDetails("null", 500));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns undefined for a body the guard rejects", function () {
|
|
216
|
+
assert.isUndefined(
|
|
217
|
+
parseProblemDetails(JSON.stringify({ ...problem, status: 200 }), 500)
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { PROBLEM_TYPE_PREFIX } from "./errors.js";
|
|
2
|
+
import { ProblemDetails } from "./interfaces.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether an untrusted value can be rethrown as an {@link ApiError} that
|
|
6
|
+
* faithfully represents the upstream failure.
|
|
7
|
+
*
|
|
8
|
+
* Every internal service client in this repo gets a non-2xx response from
|
|
9
|
+
* another Zuplo service and wants to rethrow its problem+json rather than
|
|
10
|
+
* flatten it into a generic 500. The naive form of that check —
|
|
11
|
+
* `if (body.type && body.status && body.title)` — is wrong in four ways, and
|
|
12
|
+
* each one turned a diagnosable upstream failure into an opaque one:
|
|
13
|
+
*
|
|
14
|
+
* - **Not an object.** `null` is valid JSON, and reading `.type` off it throws
|
|
15
|
+
* a `TypeError` out of the client. An array passes a `typeof === "object"`
|
|
16
|
+
* guard and then fails later.
|
|
17
|
+
* - **Wrong member types.** A `status` of `"400"` is truthy, so it reached
|
|
18
|
+
* `ApiError` as a string.
|
|
19
|
+
* - **A `type` outside {@link PROBLEM_TYPE_PREFIX}.** `ApiError`'s constructor
|
|
20
|
+
* rejects those, so passing one through throws a plain `Error` about the
|
|
21
|
+
* type instead of surfacing the problem. RFC 7807's default `about:blank`
|
|
22
|
+
* lands here.
|
|
23
|
+
* - **A `status` that disagrees with the response, or that is not an error
|
|
24
|
+
* status at all.** This is the dangerous one. `handleError` copies
|
|
25
|
+
* `ApiError.status` straight into `reply.status(...)` (`@zuplo/server`'s
|
|
26
|
+
* `fastify/error-handler.ts`), so the body dictates the response status.
|
|
27
|
+
* Two guards are needed, not one:
|
|
28
|
+
* - It must equal `httpStatus`. Otherwise a body claiming `200` serves an
|
|
29
|
+
* upstream failure as a success. RFC 7807 says `status` SHOULD match the
|
|
30
|
+
* response, so equality costs nothing on a conforming body.
|
|
31
|
+
* - It must be a 4xx or 5xx. Equality alone is not enough, because callers
|
|
32
|
+
* do not all gate on `>= 400`: `ApiKeyService` treats every
|
|
33
|
+
* `status >= 300` as a failure and `Response.ok` is false for 3xx too. A
|
|
34
|
+
* `301` response carrying a body that also says `301` would satisfy
|
|
35
|
+
* equality and turn into `reply.status(301)` — an upstream-dictated
|
|
36
|
+
* redirect emitted from an error path.
|
|
37
|
+
* - **Non-string `detail` or `instance`.** Both are declared `string`, and
|
|
38
|
+
* `handleError` copies them into the response body while
|
|
39
|
+
* `request.audit.setError` puts `detail` into the audit event. Left
|
|
40
|
+
* unchecked, an object or array from an untrusted body reaches both while
|
|
41
|
+
* the types claim it is a string.
|
|
42
|
+
*
|
|
43
|
+
* `httpStatus` is the status the upstream service actually responded with.
|
|
44
|
+
*
|
|
45
|
+
* Extra members are preserved: RFC 7807 allows them and callers pass them
|
|
46
|
+
* through to `ApiError.extra`.
|
|
47
|
+
*/
|
|
48
|
+
export function isRethrowableProblem(
|
|
49
|
+
value: unknown,
|
|
50
|
+
httpStatus: number
|
|
51
|
+
): value is ProblemDetails {
|
|
52
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const problem = value as Partial<ProblemDetails>;
|
|
57
|
+
return (
|
|
58
|
+
typeof problem.type === "string" &&
|
|
59
|
+
typeof problem.title === "string" &&
|
|
60
|
+
isErrorStatus(problem.status) &&
|
|
61
|
+
problem.status === httpStatus &&
|
|
62
|
+
problem.type.startsWith(PROBLEM_TYPE_PREFIX) &&
|
|
63
|
+
isAbsentOrString(problem.detail) &&
|
|
64
|
+
isAbsentOrString(problem.instance)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a value is an integer HTTP status in the 4xx or 5xx range. */
|
|
69
|
+
function isErrorStatus(value: unknown): value is number {
|
|
70
|
+
return (
|
|
71
|
+
typeof value === "number" &&
|
|
72
|
+
Number.isInteger(value) &&
|
|
73
|
+
value >= 400 &&
|
|
74
|
+
value <= 599
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether an optional problem member is either absent or a string. */
|
|
79
|
+
function isAbsentOrString(value: unknown): boolean {
|
|
80
|
+
return value === undefined || typeof value === "string";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parses an upstream error body and returns its problem detail, or `undefined`
|
|
85
|
+
* when the body is not one {@link isRethrowableProblem} accepts.
|
|
86
|
+
*
|
|
87
|
+
* Takes the body as text rather than a parsed value because a failing internal
|
|
88
|
+
* service is not guaranteed to answer with JSON at all: an HTML error page
|
|
89
|
+
* from a load balancer, or an empty body on a Cloud Run cold-start timeout,
|
|
90
|
+
* makes `Response.json()` throw a `SyntaxError` that escapes the caller as an
|
|
91
|
+
* opaque 500 — and skips the log that would have recorded what the upstream
|
|
92
|
+
* actually returned. Read `res.text()` and hand it here instead.
|
|
93
|
+
*
|
|
94
|
+
* Callers that already hold a parsed body (an SDK that deserializes for them)
|
|
95
|
+
* should use {@link isRethrowableProblem} directly rather than re-serializing.
|
|
96
|
+
*/
|
|
97
|
+
export function parseProblemDetails(
|
|
98
|
+
rawBody: string,
|
|
99
|
+
httpStatus: number
|
|
100
|
+
): ProblemDetails | undefined {
|
|
101
|
+
let parsed: unknown;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(rawBody);
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return isRethrowableProblem(parsed, httpStatus) ? parsed : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Caps how much of an unparsed upstream error body reaches the logs. A proxy
|
|
113
|
+
* error page is measured in kilobytes and the first few lines are enough to
|
|
114
|
+
* tell an HTML 502 from a truncated JSON payload.
|
|
115
|
+
*/
|
|
116
|
+
export const MAX_LOGGED_PROBLEM_BODY_LENGTH = 512;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Trims an upstream error body to {@link MAX_LOGGED_PROBLEM_BODY_LENGTH} for
|
|
120
|
+
* logging, marking it when anything was dropped.
|
|
121
|
+
*
|
|
122
|
+
* Log the body whenever {@link parseProblemDetails} returns `undefined`:
|
|
123
|
+
* that is the branch where the upstream failure gets flattened into a generic
|
|
124
|
+
* 500, so the body is the only remaining evidence of what actually happened.
|
|
125
|
+
*/
|
|
126
|
+
export function truncateProblemBody(body: string): string {
|
|
127
|
+
return body.length > MAX_LOGGED_PROBLEM_BODY_LENGTH
|
|
128
|
+
? `${body.slice(0, MAX_LOGGED_PROBLEM_BODY_LENGTH)}...[truncated]`
|
|
129
|
+
: body;
|
|
130
|
+
}
|