Skip to content

feat(error): coerce thrown number/string to HTTPError#1372

Merged
pi0 merged 3 commits into
h3js:mainfrom
onmax:feat/throw-status-shorthand
Apr 29, 2026
Merged

feat(error): coerce thrown number/string to HTTPError#1372
pi0 merged 3 commits into
h3js:mainfrom
onmax:feat/throw-status-shorthand

Conversation

@onmax

@onmax onmax commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Before, throwing a 404 needed throw new HTTPError({ status: 404 }) or the deprecated createError. This coerces thrown numbers at the rejection boundary in toResponse, so this now works:

throw 404

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

  • Bug Fixes
    • Improved handling of numeric values in promise rejections to properly set HTTP error status codes in asynchronous operations.

@onmax
onmax requested a review from pi0 as a code owner April 21, 2026 09:15
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Promise rejection handling was refactored in the response module from a catch-and-resolve pattern to a two-argument .then() form, with numeric rejection values now converted to HTTPError objects before recursive processing. Test cases were added to verify synchronous number throws, async rejection of numbers, and return value handling.

Changes

Cohort / File(s) Summary
Promise Rejection Handling
src/response.ts
Modified error handling in toResponse to use two-argument .then() instead of .catch(), normalizing numeric rejections to HTTPError({ status: r }) before recursion.
Error Coercion Tests
test/errors.test.ts
Added three test cases verifying behavior of numeric values when thrown synchronously, thrown from async handlers, and returned as values.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • pi0

Poem

🐰 A promise once tangled, now clear as can be,
Numerals transform to errors with glee,
Two arguments dancing in .then()'s embrace,
Rejections now handled with elegant grace,
Tests verify truth from each hop and each bound! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(error): coerce thrown number/string to HTTPError' accurately summarizes the main change—converting thrown primitives (numbers and strings) into HTTPError instances for improved error handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/response.ts (1)

245-251: Consider guarding against invalid numeric status codes.

coerceThrown forwards any number to HTTPError({ status: val }), including 0, negatives, NaN, non-integers, and values outside the HTTP range (e.g. throw 9999 or throw -1). Depending on how HTTPError normalizes 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" or throw "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 describeMatrix so both web/node runtimes exercise the rejection path in toResponse.

One optional addition: a negative test (e.g., throw "hello" or throw { 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 the return val fallback.

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between e2f8cbd and b63ee5f.

📒 Files selected for processing (3)
  • src/response.ts
  • test/bench/bundle.test.ts
  • test/errors.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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's ErrorDetails overload, and sanitizeStatusCode will 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 in prepareResponseBody, 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 where throw "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

📥 Commits

Reviewing files that changed from the base of the PR and between b63ee5f and ba95ceb.

📒 Files selected for processing (2)
  • src/response.ts
  • test/errors.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/errors.test.ts

@pi0 pi0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@pkg-pr-new

pkg-pr-new Bot commented Apr 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/h3@1372

commit: 95ae936

@pi0

pi0 commented Apr 29, 2026

Copy link
Copy Markdown
Member

Lets ship it!

@pi0
pi0 merged commit 351b2aa into h3js:main Apr 29, 2026
6 checks passed
@onmax
onmax deleted the feat/throw-status-shorthand branch April 29, 2026 11:20
pi0x pushed a commit that referenced this pull request Jul 18, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants