Skip to content

fix(clients): build URL after request interceptors + thread finalError through error chain#3804

Open
jnsdls wants to merge 4 commits intohey-api:mainfrom
jnsdls:fix/client-next-interceptor-order
Open

fix(clients): build URL after request interceptors + thread finalError through error chain#3804
jnsdls wants to merge 4 commits intohey-api:mainfrom
jnsdls:fix/client-next-interceptor-order

Conversation

@jnsdls
Copy link
Copy Markdown

@jnsdls jnsdls commented Apr 21, 2026

Summary

Fixes #3803. Two closely-related bugs in the generated client.gen.ts templates:

1. client-next builds the URL before request interceptors run

beforeRequest() in packages/openapi-ts/src/plugins/@hey-api/client-next/bundle/client.ts returned both opts and a pre-computed url. Then:

const { opts, url } = await beforeRequest(options);

for (const fn of interceptors.request.fns) {
  if (fn) {
    await fn(opts);           // interceptor mutates opts.baseUrl / opts.url / opts.path / opts.query
  }
}

let response = await _fetch(url, requestInit);   // …but url was closed over BEFORE the loop

Any request interceptor that mutates opts.baseUrl, opts.url, opts.path, or opts.query — the documented way to dynamically route requests (e.g. per-tenant base URLs, test env rewrites, migration shims) — had no effect. The pre-interceptor URL was already captured by the fetch call.

Move const url = buildUrl(opts) to AFTER the request interceptor loop. Same change applied to makeSseFn so SSE paths are consistent.

This bug is unique to client-next because client-fetch and client-ky use Request objects that interceptors mutate directly, so the URL picks up changes via request.url. The client-next design runs interceptors on opts directly, which makes post-interceptor URL recomputation mandatory.

2. Error interceptor chain drops transformations (client-next, client-ky, client-fetch)

All three templates had:

let finalError = error;
for (const fn of interceptors.error.fns) {
  if (fn) {
    finalError = (await fn(error, )) as string;   // passing `error`, not `finalError`
  }
}

Each interceptor saw the original error rather than the previous interceptor's output, so earlier interceptors' transformations were silently discarded — composition didn't work. client-angular and client-ofetch already thread finalError correctly; this aligns the rest.

⚠️ Behavioral change — please read before merging. Fixing the error-chain bug changes observable behavior for anyone who installed 2 or more error interceptors:

  • Before: each interceptor received the original error; only the last interceptor's return value survived. A chain behaved as if only the final interceptor was installed.
  • After: each interceptor receives the previous interceptor's output (standard middleware composition, matching request/response interceptors and client-angular/client-ofetch).
  • Gotcha: if an interceptor returns a falsy value (e.g. undefined) mid-chain, the accumulated error is cleared for subsequent interceptors. Interceptors should always return an error-shaped value.

This is called out explicitly in the changeset as well.

Test plan

  • pnpm vitest run — all 2086 tests pass (3 pre-existing skips)
  • pnpm lint — clean (oxfmt + eslint)
  • pnpm typecheck — clean (35 tasks)
  • Regenerated 183 client.gen.ts snapshots across @test/openapi-ts, @test/openapi-ts-nestjs-v11, @test/openapi-ts-orpc-v1, @test/openapi-ts-sdks, @test/openapi-ts-valibot-v1, @test/openapi-ts-zod-v3, @test/openapi-ts-zod-v4
  • Added regression tests covering URL-after-interceptor mutations (baseUrl/url/path/query) and error-chain composition in client-next, client-fetch, and client-ky test suites — revert the source fixes and these new tests fail
  • Changeset added (@hey-api/openapi-ts: patch) with explicit behavioral-change note

Notes

  • Source change is 3 files, ~15 insertions / 8 deletions total
  • Snapshot diffs are mechanical propagation of the source change through every fixture that renders one of the three affected client templates
  • Happy to split into two PRs if preferred (URL-recompute vs error-chain), but the changes touch the same region of the same files and the combined test run is cleaner

…r through error chain

`client-next`: `beforeRequest()` computed the final URL before any request
interceptor ran, so request interceptors that mutated `opts.baseUrl`,
`opts.url`, `opts.path`, or `opts.query` had no effect — the pre-interceptor
URL was already closed over by the fetch call. Move `buildUrl(opts)` to
after the request interceptor loop in both the `request` and `makeSseFn`
paths so interceptor mutations are honored.

`client-next`, `client-ky`, `client-fetch`: error interceptor chains passed
`error` (the raw initial error) into every interceptor invocation instead
of `finalError` (the running accumulator). This broke composition: each
interceptor saw the original error rather than the previous interceptor's
output, so transformations from earlier interceptors were silently
discarded. Thread `finalError` through instead, matching how `client-angular`
and `client-ofetch` already work.

Fixes hey-api#3803.
@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: 7f788f0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hey-api/openapi-ts Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

@jnsdls is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@dosubot dosubot Bot added size:XS This PR changes 0-9 lines, ignoring generated files. bug 🔥 Broken or incorrect behavior. labels Apr 21, 2026
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 21, 2026

TL;DR — Fixes two bugs in the generated HTTP client templates: client-next now builds the request URL after request interceptors run (so interceptor mutations to baseUrl/url/path/query are actually honored), and all three clients (client-next, client-fetch, client-ky) now thread finalError through the error interceptor chain instead of always passing the original error. Adds regression tests for both fixes.

Key changes

  • Move buildUrl() after request interceptors in client-nextbeforeRequest() no longer pre-computes the URL; it is built after interceptors have had a chance to mutate opts
  • Thread finalError through error interceptor chains — error interceptors in client-fetch, client-ky, and client-next now receive the previous interceptor's output instead of the original error, aligning with client-angular and client-ofetch
  • Add regression tests for both fixesclient-next gets four tests verifying interceptor mutations to baseUrl, url, path, and query are honored; all three clients get chain-composition tests proving finalError flows through
  • Document behavioral change in changeset — the changeset now includes a detailed migration note warning that users with ≥2 error interceptors will see different error payloads after upgrading
  • Update generated client.gen.ts snapshots — mechanical propagation of the two source-level fixes across all test fixtures and examples

Summary | 200 files | 4 commits | base: mainfix/client-next-interceptor-order


Deferred URL construction in client-next

Before: buildUrl(opts) was called inside beforeRequest(), before request interceptors ran — any interceptor mutation to opts.baseUrl, opts.url, opts.path, or opts.query was silently ignored.
After: buildUrl(opts) runs after the request interceptor loop, so all mutations are reflected in the final fetch URL.

The request() function now destructures only { opts } from beforeRequest() and calls buildUrl(opts) once interceptors have finished. The same pattern is applied to the SSE path in makeSseFn, with a comment explaining that SSE re-applies interceptors internally.

Why doesn't this affect client-fetch and client-ky? Those clients pass a Request object through interceptors, so URL mutations happen on the request object itself and are picked up automatically. client-next passes plain opts, making explicit URL recomputation necessary.

client-next/bundle/client.ts · client-next/__tests__/client.test.ts


Error interceptor chain composition fix

Before: Every error interceptor received the original error, discarding transformations from earlier interceptors in the chain.
After: Each interceptor receives finalError — the accumulated result of all preceding interceptors.

A single-token change (errorfinalError) in the error interceptor loop of each client template. This matches the existing correct behavior in client-angular and client-ofetch. New regression tests in all three affected clients verify that the second interceptor sees the first interceptor's output and the final result.error carries transformations from the entire chain.

Is this a breaking change? Technically yes for users with ≥2 error interceptors. The changeset includes a migration note: if you relied on the previous "only the last interceptor wins" behavior, error payloads will differ after upgrading. Interceptors should always return an error-shaped value — returning a falsy value (e.g. undefined) clears the accumulated error for subsequent interceptors.

client-fetch/bundle/client.ts · client-ky/bundle/client.ts · client-next/bundle/client.ts

Pullfrog  | View workflow run | via Pullfrog𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Reviewed — no issues found.

Both fixes are correct and well-scoped. Moving buildUrl() after request interceptors in client-next ensures URL mutations from interceptors are honored, and threading finalError through the error chain aligns client-fetch, client-ky, and client-next with the already-correct behavior in client-angular and client-ofetch. All 183 snapshot changes are mechanical propagation of the three source template edits.

Task list (4/4 completed)
  • Read changeset and PR description for context
  • Review core client source changes (client-fetch, client-ky, client-next)
  • Review snapshot changes for consistency
  • Self-critique and submit review

Pullfrog  | View workflow run𝕏

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 77.77778% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.88%. Comparing base (6cf6b1a) to head (7f788f0).

Files with missing lines Patch % Lines
.../src/plugins/@hey-api/client-next/bundle/client.ts 66.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3804      +/-   ##
==========================================
+ Coverage   39.80%   39.88%   +0.08%     
==========================================
  Files         530      530              
  Lines       19467    19468       +1     
  Branches     5791     5791              
==========================================
+ Hits         7748     7765      +17     
+ Misses       9487     9478       -9     
+ Partials     2232     2225       -7     
Flag Coverage Δ
unittests 39.88% <77.77%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

jnsdls added 2 commits April 20, 2026 21:43
Propagates the client template changes (URL built after request interceptors;
finalError threaded through error interceptor chain) into the 10 example
projects whose generated client.gen.ts files render one of the three affected
client templates. This unblocks the examples:check CI job.
Adds regression tests that fail on pre-fix source and pass on post-fix source:

- client-next: four tests asserting that request interceptor mutations to
  `opts.baseUrl`, `opts.url`, `opts.path`, and `opts.query` are reflected in
  the final fetch URL — previously the URL was built before interceptors ran,
  so these mutations were silently dropped.
- client-next / client-fetch / client-ky: error-chain composition tests that
  install two error interceptors and assert the second sees the first's
  output (not the original error). Previously `finalError` was reassigned but
  the loop kept passing the untransformed `error` to each interceptor.

client-fetch gets two variants (fetch-exception path and response-error path)
since the bug existed in both interceptor loops.
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:XS This PR changes 0-9 lines, ignoring generated files. labels Apr 21, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 21, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3804

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3804

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3804

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3804

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3804

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3804

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3804

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3804

commit: 7f788f0

@jnsdls
Copy link
Copy Markdown
Author

jnsdls commented Apr 21, 2026

👋 Heads-up for reviewers: the error-interceptor fix in this PR is a subtle behavioral change for anyone who installed 2 or more error interceptors.

Before this PR: each interceptor received the original error, and only the last interceptor's return value survived. A chain of N interceptors effectively behaved as if only the final one was installed.

After this PR: each interceptor receives the previous interceptor's output (standard middleware composition — matches request/response interceptors, and matches how client-angular / client-ofetch already worked).

Gotcha to watch for: if an interceptor returns a falsy value (e.g. undefined) mid-chain, the accumulated error is cleared for the remainder of the chain. Interceptors should always return an error-shaped value.

This caveat is now explicitly called out in both the changeset and the PR description. Flagging here so it's visible in the PR timeline before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug 🔥 Broken or incorrect behavior. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@hey-api/client-next: buildUrl called before request interceptors, so interceptor mutations to baseUrl/url/path/query are ignored by fetch()

1 participant