Skip to content

fix: only discard prepared headers for error responses#1486

Merged
pi0 merged 2 commits into
mainfrom
fix/res-headers
Jul 18, 2026
Merged

fix: only discard prepared headers for error responses#1486
pi0 merged 2 commits into
mainfrom
fix/res-headers

Conversation

@pi0x

@pi0x pi0x commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Closes #1481

Problem

prepareResponse guarded header merging with !val.ok (src/response.ts:118), but Response.ok is false for all non-2xx statuses — not just errors. So prepared headers were dropped for intentional redirects and deliberate 4xx too:

app.get("/login-redirect", (event) => {
  setCookie(event, "sid", "1");
  return new Response(null, { status: 302, headers: { location: "/login" } });
});
// → set-cookie silently absent

return redirect(event, "/login") kept the cookie (it returns an HTTPResponse, which takes the merging path), so the two forms disagreed.

While confirming the report I found a second gap: event.res.errHeaders — the documented escape hatch for headers that must survive an error render, which handleCors writes to — was also dropped by this fast path. CORS headers survived a thrown 401 but vanished on a returned one:

302 set-cookie: null     ← reported bug
401 cors:      null      ← errHeaders dropped on returned 401
thrown cors:   *         ← errHeaders survive on thrown error

Fix

Scope the guard to error statuses and honor errHeaders there, matching what errorResponse() already does:

  • < 400 — merge all prepared headers (fixes redirects/cookies; makes redirect() and a native 302 behave alike)
  • >= 400 — merge only errHeaders (fixes CORS on a returned 401)

Success-only headers (caching, content negotiation, ...) still never leak into error responses, so #1228's intent is preserved. A custom error render returned from onError is still left untouched.

Behavior change

Returning a non-2xx Response no longer drops everything staged on event.res. Code that relied on event.res.headers being discarded for a 3xx will now see those headers on the response; error statuses are unchanged except that errHeaders are now applied.

Tests

Three regression tests in test/app.test.ts, all failing on main:

  • redirect Response keeps setCookie
  • error Response drops cache-control but keeps errHeaders
  • returned and thrown 401 agree on CORS headers

Note on bundle size

The fix costs +19 bytes minified (+8 gzip), and both budgets were at their ceiling — defineHandler 6396→6415 against a 6400 limit, and H3Core 7281→7300 against a 7300 limit (i.e. passing with zero slack, which would have broken the next PR regardless). Raised to 6450/7350.

I tried hoisting the preparedRes read above the error branch to dedupe the kEventRes lookup and buy the bytes back; it came out a byte worse, so I reverted it. Happy to pay for the bytes elsewhere if you'd prefer the budgets held.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Redirect responses now retain prepared headers, including cookies.
    • Error responses discard regular prepared headers while preserving explicitly configured error headers.
    • Returned and thrown HTTP errors now apply error headers consistently.
  • Documentation

    • Clarified header behavior for success, redirect, and error responses.
  • Tests

    • Added coverage for redirect cookies and error-header handling.

`prepareResponse` used `!val.ok` to decide whether to merge prepared
headers into a returned `Response`, which dropped them for every non-2xx
status — including intentional 3xx redirects and deliberate 4xx/5xx
responses. `setCookie()` before `return new Response(null, {status: 302})`
silently lost the cookie, while `return redirect(event, "/x")` kept it.

Scope the guard to error statuses and honor `event.res.errHeaders` there,
matching what `errorResponse()` already does for thrown errors:

- `< 400`: merge all prepared headers (fixes redirects and cookies)
- `>= 400`: merge only `errHeaders` (fixes CORS headers being dropped on a
  returned 401, which previously survived only when the error was thrown)

Success-only headers (caching, content negotiation, ...) still never leak
into error responses, preserving the intent of #1228.

Closes #1481

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@pi0x
pi0x requested a review from pi0 as a code owner July 18, 2026 09:04
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8af9e6ae-7f7e-4e3a-96e7-286d11b4969c

📥 Commits

Reviewing files that changed from the base of the PR and between 5d0194a and c52e4c8.

📒 Files selected for processing (4)
  • docs/1.guide/1.basics/5.response.md
  • src/response.ts
  • test/app.test.ts
  • test/bench/bundle.test.ts

📝 Walkthrough

Walkthrough

Changes

Response header behavior

Layer / File(s) Summary
Status-aware header finalization
src/response.ts, test/app.test.ts, docs/1.guide/1.basics/5.response.md
Prepared headers remain on redirect responses, while error responses use errHeaders; tests and documentation cover both behaviors.

Bundle size baselines

Layer / File(s) Summary
Bundle size threshold updates
test/bench/bundle.test.ts
Uncompressed size limits are increased for the H3Core and defineHandler bundles.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

  • h3js/h3#1259: Updates the same prepared-header merging path in src/response.ts.
  • h3js/h3#1352: Introduces event.res.errHeaders, which this change selects for error responses.

Suggested reviewers: pi0

Poem

I’m a rabbit with cookies tucked tight,
Redirects now carry them right.
Error headers choose their own thread,
CORS stays where it’s gently spread.
Bundles grow by bytes, not fright—
Hop, hop, review feels light!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main fix: preserving prepared headers except for error responses.
Linked Issues check ✅ Passed The code now scopes header dropping to status >= 400, preserving cookies and other prepared headers on redirects as required by #1481.
Out of Scope Changes check ✅ Passed The docs, regression tests, and bundle-budget adjustments all support the header-handling fix and stay within scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/res-headers

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.

@pi0
pi0 merged commit 0c1af57 into main Jul 18, 2026
5 of 7 checks passed
@pi0
pi0 deleted the fix/res-headers branch July 18, 2026 09:12
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.

Returning a non-2xx native Response drops prepared headers including cookies (!val.ok guard)

2 participants