feat(error): coerce thrown number/string to HTTPError#1372
Conversation
📝 WalkthroughWalkthroughPromise rejection handling was refactored in the response module from a catch-and-resolve pattern to a two-argument Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/response.ts (1)
245-251: Consider guarding against invalid numeric status codes.
coerceThrownforwards any number toHTTPError({ status: val }), including0, negatives,NaN, non-integers, and values outside the HTTP range (e.g.throw 9999orthrow -1). Depending on howHTTPErrornormalizes status, this can surface as an invalid response status or unexpected error payload. A small sanity check (e.g., integer between 400–599, or ≥100/<600) would keep the shorthand focused on its documented use case and keep genuinely bogus throws going through the generic error path.Similarly for strings,
throw "000"orthrow "099 foo"will produce an HTTPError with a sub-100 status. Worth considering whether to narrow the regex to/^[1-5]\d{2}(\s|$)/.Proposed tightening
function coerceThrown(val: unknown): unknown { - if (typeof val === "number") return new HTTPError({ status: val }); - if (typeof val === "string" && /^\d{3}(\s|$)/.test(val)) { + if (typeof val === "number" && Number.isInteger(val) && val >= 100 && val < 600) { + return new HTTPError({ status: val }); + } + if (typeof val === "string" && /^[1-5]\d{2}(\s|$)/.test(val)) { return new HTTPError({ status: +val.slice(0, 3), message: val.slice(3).trim() || undefined }); } return val; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/response.ts` around lines 245 - 251, coerceThrown currently converts any number or three-digit string into an HTTPError (via HTTPError({ status: ... })) which allows invalid statuses like 0, negatives, NaN, non-integers or out-of-range codes; tighten validation in coerceThrown so numeric values are accepted only if they are finite integers in the valid HTTP range (e.g. between 100 and 599 or restrict to 400–599 if you prefer) and string matches only a valid status digit range (e.g. /^[1-5]\d{2}(\s|$)/); if the value fails validation, return the original val unchanged so bogus throws fall through instead of producing malformed HTTPError instances (update function coerceThrown and the string-regex logic that slices status/message and constructs new HTTPError accordingly).test/errors.test.ts (1)
164-202: Test coverage looks solid.Good coverage across the new coercion matrix: numeric throw, numeric string, status+message string, async rejection, and the return-vs-throw distinction. Uses
describeMatrixso both web/node runtimes exercise the rejection path intoResponse.One optional addition: a negative test (e.g.,
throw "hello"orthrow { foo: 1 }) to lock in that non-matching values still flow through the existing unhandled-error path unchanged — that would prevent future regressions of the regex or thereturn valfallback.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/errors.test.ts` around lines 164 - 202, Add negative tests that throw non-status values so they stay on the unhandled-error path: add one test named like "throw non-status string unchanged" that does t.app.use(() => { throw "hello"; }) and one like "throw object error unchanged" that does t.app.use(() => { throw { foo: 1 }; }), then fetch("/") and assert the response is handled as an unhandled error (i.e., not coerced to a status code) by checking res.status is 500 and the JSON body matches the existing unhandled-error response shape (use the same expectations pattern as other tests); this ensures toResponse (or the rejection handling logic) doesn't coerce non-matching values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/response.ts`:
- Around line 245-251: coerceThrown currently converts any number or three-digit
string into an HTTPError (via HTTPError({ status: ... })) which allows invalid
statuses like 0, negatives, NaN, non-integers or out-of-range codes; tighten
validation in coerceThrown so numeric values are accepted only if they are
finite integers in the valid HTTP range (e.g. between 100 and 599 or restrict to
400–599 if you prefer) and string matches only a valid status digit range (e.g.
/^[1-5]\d{2}(\s|$)/); if the value fails validation, return the original val
unchanged so bogus throws fall through instead of producing malformed HTTPError
instances (update function coerceThrown and the string-regex logic that slices
status/message and constructs new HTTPError accordingly).
In `@test/errors.test.ts`:
- Around line 164-202: Add negative tests that throw non-status values so they
stay on the unhandled-error path: add one test named like "throw non-status
string unchanged" that does t.app.use(() => { throw "hello"; }) and one like
"throw object error unchanged" that does t.app.use(() => { throw { foo: 1 }; }),
then fetch("/") and assert the response is handled as an unhandled error (i.e.,
not coerced to a status code) by checking res.status is 500 and the JSON body
matches the existing unhandled-error response shape (use the same expectations
pattern as other tests); this ensures toResponse (or the rejection handling
logic) doesn't coerce non-matching values.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 69c6c2fc-1cc2-4235-be83-32a927b183b3
📒 Files selected for processing (3)
src/response.tstest/bench/bundle.test.tstest/errors.test.ts
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/response.ts (1)
17-20: LGTM — minimal, targeted coercion.The switch to the two-argument
.then(onFulfilled, onRejected)form correctly preserves the fulfilled path and inlines the numeric coercion only on the rejection branch, avoiding the previous.catch(err => err)round-trip.new HTTPError({ status: r })is valid per the constructor'sErrorDetailsoverload, andsanitizeStatusCodewill clamp invalid numeric inputs (NaN, out-of-range, non-integer) to 500, which is a reasonable fallback.One thing worth being explicit about (matches the "drop string form" commit): non-number, non-Error primitive rejections (e.g.
throw "nope",throw true) pass through unchanged and end up inprepareResponseBody, so they'll be serialized into a 200 response body rather than treated as errors. That's consistent with the PR intent of only coercing numbers, but it may surprise users coming from frameworks wherethrow "msg"implies an error — worth a short note in the docs/changelog if not already covered.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/response.ts` around lines 17 - 20, The change in response.ts ensures numeric rejection values are coerced to HTTPError in the rejection branch of the Promise.then call inside toResponse, but non-number, non-Error primitive rejections (e.g., throw "nope" or throw true) will pass through to prepareResponseBody and be serialized as a 200 response; update the docs/changelog to explicitly note this behavior change and the rationale (only numeric coercion is applied, other primitives are not converted to errors), referencing toResponse, prepareResponseBody, and the HTTPError constructor so readers can locate the relevant code paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/response.ts`:
- Around line 17-20: The change in response.ts ensures numeric rejection values
are coerced to HTTPError in the rejection branch of the Promise.then call inside
toResponse, but non-number, non-Error primitive rejections (e.g., throw "nope"
or throw true) will pass through to prepareResponseBody and be serialized as a
200 response; update the docs/changelog to explicitly note this behavior change
and the rationale (only numeric coercion is applied, other primitives are not
converted to errors), referencing toResponse, prepareResponseBody, and the
HTTPError constructor so readers can locate the relevant code paths.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 257aa6e8-9816-4180-bd31-8895c1f129f1
📒 Files selected for processing (2)
src/response.tstest/errors.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- test/errors.test.ts
pi0
left a comment
There was a problem hiding this comment.
I personally really like the idea! Sharing in discord if there are real concerns against it (throw 404 vs return 404 might be confusing pattern)
commit: |
|
Lets ship it! |
Align object throws with the `throw 404` feature from #1372: an object carrying a valid `status` (or deprecated `statusCode`) is a deliberate status shorthand, so `throw { status: 404 }` now renders exactly like `throw 404` and is not reported as an unhandled error. Values without a valid status -- strings, `undefined`, objects with no or out-of-range status -- stay unhandled 500s so they are logged rather than rendered as a successful body. Only the status is ever taken from the thrown value; `message`, `data`, `statusText` and `headers` are still dropped. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Before, throwing a 404 needed
throw new HTTPError({ status: 404 })or the deprecatedcreateError. This coerces thrown numbers at the rejection boundary intoResponse, so this now works:throw new HTTPError({...})/throw new Error()keep working unchanged. Returning a number from a handler is unchanged (still JSON-serialized).Net bundle impact: ~0 (stays under existing limits).
Addresses #1371.
I know the likelihood of getting this merged is low, but fun to explore :)
Summary by CodeRabbit