Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions packages/custom-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,33 @@
opts.headers.delete('Content-Type');
}

const url = buildUrl(opts);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
};

let request = new Request(url, requestInit);
/**
* FIX (#3803): Execute request interceptors before building the final URL.
* This ensures that any mutations made to 'opts' (e.g., baseUrl, path, query)
* by the interceptors are correctly captured during the URL construction phase.
*/

// 1. Create an initial Request object with a placeholder URL
let request = new Request('' as string, requestInit);

Check failure on line 70 in packages/custom-client/src/client.ts

View workflow job for this annotation

GitHub Actions / Upload

[@hey-api/custom-client] src/__tests__/client.test.ts > zero-length body handling > returns empty object for empty response without Content-Length header and no Content-Type (defaults to JSON)

TypeError: Failed to parse URL from ❯ Object.request src/client.ts:70:19 ❯ src/__tests__/client.test.ts:102:33 Caused by: Caused by: TypeError: Invalid URL ❯ Object.request src/client.ts:70:19 ❯ src/__tests__/client.test.ts:102:33 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '' }

Check failure on line 70 in packages/custom-client/src/client.ts

View workflow job for this annotation

GitHub Actions / Upload

[@hey-api/custom-client] src/__tests__/client.test.ts > zero-length body handling > returns empty object for empty JSON response without Content-Length header (status 200)

TypeError: Failed to parse URL from ❯ Object.request src/client.ts:70:19 ❯ src/__tests__/client.test.ts:85:33 Caused by: Caused by: TypeError: Invalid URL ❯ Object.request src/client.ts:70:19 ❯ src/__tests__/client.test.ts:85:33 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '' }

Check failure on line 70 in packages/custom-client/src/client.ts

View workflow job for this annotation

GitHub Actions / Upload

[@hey-api/custom-client] src/__tests__/client.test.ts > zero-length body handling > returns empty object for zero-length JSON response

TypeError: Failed to parse URL from ❯ Object.request src/client.ts:70:19 ❯ src/__tests__/client.test.ts:64:33 Caused by: Caused by: TypeError: Invalid URL ❯ Object.request src/client.ts:70:19 ❯ src/__tests__/client.test.ts:64:33 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_INVALID_URL', input: '' }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

new Request('') throws TypeError: Failed to parse URL from in Node.js (undici) when no global origin has been set — which is the default in SSR, serverless, CLIs, and Vitest's Node environment. Because this line is inside the outer scope of request (not the try), the first call to any client method rejects with an opaque TypeError. The '' as string cast is also a no-op — it asserts the type already inferred. This pattern is unrecoverable without passing a real absolute URL.


// 2. Process all registered request interceptors
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}

// 3. Construct the final URL using the potentially modified options
const url = buildUrl(opts);

// 4. Re-initialize the Request object with the final computed URL and original init options
request = new Request(url, requestInit);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Request returned by each interceptor is assigned to request, then immediately overwritten by new Request(url, requestInit) using the original requestInit. Any header/body/method mutation an interceptor performed on the Request object (the documented auth pattern: request.headers.set('Authorization', ...); return request) is silently dropped. Only mutations routed through opts survive, which is not how any of the existing interceptor examples are written.


// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
Expand Down Expand Up @@ -107,8 +120,6 @@
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
Expand Down Expand Up @@ -143,6 +154,10 @@
// noop
}

/**
* FIX (#3803): Implementing proper error interceptor threading.
* Ensure each error interceptor receives the result of the previous one.
*/
let finalError = error;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Threading finalError through interceptors is a real behavior change for custom-client (unlike for client-fetch/client-ky where it already landed via #3814). It needs a changeset at the major/breaking level and a migrating.md entry, mirroring the treatment given to #3814 in docs/openapi-ts/migrating.md:20-22. Please also add a regression test with at least two error interceptors asserting that the second receives the transformed value — packages/custom-client/src/__tests__/client.test.ts currently has zero interceptor tests.


for (const fn of interceptors.error.fns) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,8 @@ export const createClient = (config: Config = {}): Client => {

const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);

return { opts: resolvedOpts, url };
return { opts: resolvedOpts };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dropping url from beforeRequest's return is unnecessary churn — on main, beforeRequest computes url once via buildUrl(resolvedOpts) and the result flows to both request and makeSseFn. The change loses that single-source-of-truth and forces callers to recompute buildUrl(opts) (done twice in makeSseFn after this PR, NEW lines 266 and 270). If the goal is to defer URL construction, the right move is to drop buildUrl from beforeRequest and compute it once per caller at the correct point — not delete one field and recompute it two or three times.

};

const request: Client['request'] = async (options) => {
Expand All @@ -80,21 +79,34 @@ export const createClient = (config: Config = {}): Client => {
let response: Response | undefined;

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

/**
* Initialize request object with a placeholder URL.
* The final URL will be constructed after interceptors have finished
* to allow for potential mutation of opts (baseUrl, query, etc.).
*/
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};

request = new Request(url, requestInit);
request = new Request('' as string, requestInit);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same new Request('') runtime crash as custom-client:70. Additionally, main already has the correct pattern on this exact line: request = new Request(url, requestInit) with a real URL, followed by the interceptor loop that returns a Request, followed by _fetch(request). The interceptor's returned Request flows through unchanged — no rebuild needed, no placeholder needed. Please revert to the main pattern.


// 1. Process all request interceptors
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}

// 2. Build final URL after interceptors have potentially mutated options
const url = buildUrl(opts);

// 3. Re-initialize Request with the finalized computed URL
request = new Request(url, requestInit);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This rebuild discards the Request returned by the interceptor loop. The canonical documented pattern (docs/openapi-ts/clients/fetch.md) is request.headers.set('Authorization', 'Bearer ...'); return request, which relies on the returned Request being what fetch receives. After this line, those headers are gone — requestInit.headers is the pre-interceptor opts.headers. This is a silent auth/tracing/CSRF bypass for every user on the documented pattern, with no runtime error to flag it. The main version (no rebuild, interceptor's return value passed straight to _fetch) is correct.


// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
Expand Down Expand Up @@ -198,6 +210,11 @@ export const createClient = (config: Config = {}): Client => {

throw jsonError ?? textError;
} catch (error) {
/**
* Implementation of error interceptor threading.
* Ensures that each interceptor in the chain receives the processed error
* from the previous one.
*/
let finalError = error;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The loop body below this comment is byte-identical to main — the threading logic landed in #3814 (see CHANGELOG.md:30,36,48 and docs/openapi-ts/migrating.md:20-22). The PR description claims "Error Threading" as a new fix for fetch; it's not. Please drop this comment block and the misleading claim from the PR description, or remove the fetch file from the PR entirely if no other substantive change remains.


for (const fn of interceptors.error.fns) {
Expand Down Expand Up @@ -227,22 +244,30 @@ export const createClient = (config: Config = {}): Client => {
request({ ...options, method });

const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
const { opts } = await beforeRequest(options);

/**
* SSE Implementation: Defer URL construction to ensure onRequest
* interceptors can properly mutate the request flow.
*/
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
onRequest: async (initialUrl, init) => {
let request = new Request(initialUrl, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;

// Re-build final URL after interceptors to capture mutations
const finalUrl = buildUrl(opts);
return new Request(finalUrl, init);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two issues: (1) the request built and mutated by the interceptor loop above is discarded — the final return new Request(finalUrl, init) uses the original init, not the interceptor's returned Request. Same silent-mutation-loss bug as line 108. (2) buildUrl(opts) is called here and again at line 270 (url: buildUrl(opts)). The outer call at 270 happens before onRequest runs, so if interceptors are meant to mutate opts for URL construction, the outer url: field is always stale. Reading serverSentEvents.ts: the outer url is used to construct the initial Request on each retry iteration before onRequest overwrites it, so the duplication is at minimum wasted work, and potentially bugged on reconnect.

},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
url: buildUrl(opts),
});
};

Expand Down
60 changes: 38 additions & 22 deletions packages/openapi-ts/src/plugins/@hey-api/client-ky/bundle/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export const createClient = (config: Config = {}): Client => {
...options,
headers: mergeHeaders(_config.headers, options.headers),
ky: options.ky ?? _config.ky ?? ky,
// deep merge kyOptions to ensure base _config is being respected
kyOptions: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This comment (// deep merge kyOptions to ensure base _config is being respected) is unrelated to the PR's stated scope. If the removal is deliberate (the merge is actually shallow, so the comment is misleading), split it into its own commit with a proper message. Otherwise please restore it.

..._config.kyOptions,
...options.kyOptions,
Expand Down Expand Up @@ -70,9 +69,8 @@ export const createClient = (config: Config = {}): Client => {

const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);

return { opts: resolvedOpts, url };
return { opts: resolvedOpts };
};

const parseErrorResponse = async (
Expand All @@ -96,6 +94,12 @@ export const createClient = (config: Config = {}): Client => {
}

const error = jsonError ?? textError;

/**
* Implementation of error interceptor threading.
* Ensures that each interceptor in the chain receives the processed error
* from the previous one.
*/
let finalError = error;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as client-fetch: the threading logic below this comment is already on main via #3814 — this comment adds nothing. Please drop.


for (const fn of interceptorsMiddleware.error.fns) {
Expand Down Expand Up @@ -127,10 +131,9 @@ export const createClient = (config: Config = {}): Client => {
let errorInterceptorsInvoked = false;

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

const kyInstance = opts.ky!;

const validBody = getValidRequestBody(opts);

const kyOptions: KyOptions = {
Expand All @@ -152,7 +155,12 @@ export const createClient = (config: Config = {}): Client => {
retry: opts.retry ?? opts.kyOptions?.retry ?? 2,
};

request = new Request(url, {
/**
* Initialize request object with a placeholder URL.
* The final URL will be constructed after interceptors have finished
* to allow for potential mutation of opts (baseUrl, query, etc.).
*/
request = new Request('' as string, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same new Request('') runtime crash as the other clients. Additionally, as with client-fetch, main already builds a real Request here, runs interceptors, and passes the interceptor-returned Request to kyInstance(request, kyOptions). The placeholder-then-rebuild pattern is a regression, not a fix.

body: kyOptions.body,
headers: kyOptions.headers as HeadersInit,
method: kyOptions.method,
Expand All @@ -164,6 +172,16 @@ export const createClient = (config: Config = {}): Client => {
}
}

// Re-build final URL after interceptors to capture mutations
const url = buildUrl(opts);

// Re-initialize Request with the finalized computed URL
request = new Request(url, {
body: kyOptions.body,
headers: kyOptions.headers as HeadersInit,
method: kyOptions.method,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rebuild discards the interceptor-returned Request (same silent-header-loss bug as client-fetch:108). Additionally asymmetric with the placeholder at line 163: the rebuild passes { body, headers, method } but the placeholder passed the full kyOptions set. Any signal, credentials, redirect, mode, etc. set on the interceptor's Request or derived from kyOptions and baked into the placeholder Request are lost. ky mitigates some of this by re-merging from kyOptions at kyInstance(request, kyOptions), but the asymmetry is a code-smell and easy to get wrong on future maintenance.


try {
response = await kyInstance(request, kyOptions);
} catch (error) {
Expand All @@ -176,9 +194,6 @@ export const createClient = (config: Config = {}): Client => {
}
}

// parseErrorResponse will run error interceptors, and re-throw when
// throwOnError is true, which bubbles already intercepted error to
// outer catch. With this flag, we can avoid outer catch running interceptors again
errorInterceptorsInvoked = true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This removal dropped a load-bearing comment explaining why errorInterceptorsInvoked exists: parseErrorResponse runs error interceptors and, with throwOnError, rethrows the already-intercepted error into the outer catch, which would run interceptors a second time without this guard. The invariant is still active — errorInterceptorsInvoked = true (line 197, 289) gates the outer catch at line 293. Without the comment, a future reader seeing errorInterceptorsInvoked = true; return parseErrorResponse(...) has no explanation for the flag. Please restore.

return parseErrorResponse(response, request, opts, interceptors);
}
Expand Down Expand Up @@ -239,8 +254,6 @@ export const createClient = (config: Config = {}): Client => {
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
Expand Down Expand Up @@ -272,18 +285,15 @@ export const createClient = (config: Config = {}): Client => {
};
}

// parseErrorResponse will run error interceptors, and re-throw when
// throwOnError is true, which bubbles already intercepted error to
// outer catch. With this flag, we can avoid outer catch running interceptors again
errorInterceptorsInvoked = true;
return parseErrorResponse(response, request, opts, interceptors);
} catch (error) {
let finalError = error;

// error may already be processed by parseErrorResponse, in this case
// we can skip running interceptors again
if (!errorInterceptorsInvoked) {
// run error interceptors for errors not already handled by parseErrorResponse
/**
* Error Interceptor Threading for standard caught errors.
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The removed comment (error may already be processed by parseErrorResponse, in this case we can skip running interceptors again and run error interceptors for errors not already handled by parseErrorResponse) documents the double-invocation invariant — same rationale as line 197. The new JSDoc (Error Interceptor Threading for standard caught errors) describes what the loop does, not why the guard exists, which is the non-obvious part. Please restore the original explanatory comments.

for (const fn of interceptors.error.fns) {
if (fn) {
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
Expand Down Expand Up @@ -311,23 +321,29 @@ export const createClient = (config: Config = {}): Client => {
request({ ...options, method });

const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
const { opts } = await beforeRequest(options);

/**
* SSE Implementation: Defer URL construction to ensure onRequest
* interceptors can properly mutate the request flow.
*/
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
fetch: globalThis.fetch,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
onRequest: async (initialUrl, init) => {
let request = new Request(initialUrl, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
const finalUrl = buildUrl(opts);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same bugs as client-fetch:267: the interceptor's returned Request is discarded in favor of new Request(finalUrl, init) with the original init, and buildUrl(opts) is called both here (342) and in the outer url: buildUrl(opts) at 346, so the outer value is pre-interceptor and stale for reconnects.

return new Request(finalUrl, init);
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
url: buildUrl(opts),
});
};

Expand Down
Loading