Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions src/common/exceptions/unprocessable-entity.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ export class UnprocessableEntityException

constructor({
code,
error,
errors,
message,
requestID,
}: {
code?: string;
error?: string;
errors?: UnprocessableEntityError[];
message?: string;
requestID: string;
Expand All @@ -28,6 +30,8 @@ export class UnprocessableEntityException

if (message) {
this.message = message;
} else if (error) {
this.message = `Error: ${error}`;
}

if (code) {
Expand Down
2 changes: 1 addition & 1 deletion src/common/net/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface {
message: `Request timeout after ${timeout}ms`,
response: {
status: 408,
headers: {},
headers: new Headers(),
data: { error: 'Request timeout' },
},
});
Expand Down
75 changes: 75 additions & 0 deletions src/workos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,81 @@ describe('WorkOS', () => {
});
});

describe('when the api responds with a 422 and an error but no message', () => {
it('surfaces the error in the exception message', async () => {
fetchOnce(
{ error: 'Maximum unique keys reached' },
{ status: 422, headers: { 'X-Request-ID': 'a-request-id' } },
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

await expect(workos.post('/path', {})).rejects.toMatchObject({
message: 'Error: Maximum unique keys reached',
requestID: 'a-request-id',
status: 422,
});
});

it('prefers the message over the error when both are present', async () => {
fetchOnce(
{ error: 'an-error', message: 'A message' },
{ status: 422, headers: { 'X-Request-ID': 'a-request-id' } },
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

await expect(workos.post('/path', {})).rejects.toMatchObject({
message: 'A message',
status: 422,
});
});

it('still formats field errors when present', async () => {
fetchOnce(
{
error: 'an-error',
errors: [{ field: 'a-field', code: 'a-requirement' }],
},
{ status: 422, headers: { 'X-Request-ID': 'a-request-id' } },
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');

await expect(workos.post('/path', {})).rejects.toMatchObject({
message: 'The following requirement must be met:\n\ta-requirement\n',
status: 422,
});
});
});

describe('when the request times out', () => {
it('surfaces the timeout rather than a headers TypeError', async () => {
const neverResolvingFetch = jest.fn(
(_url: any, init: any) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
reject(abortError);
});
}) as any,
);

const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', {
timeout: 1,
fetchFn: neverResolvingFetch as any,
});

await expect(workos.post('/path', {})).rejects.toMatchObject({
name: 'OauthException',
status: 408,
requestID: '',
message: 'Error: Request timeout',
});
}, 20000);
});

describe('when the api responds with a 500 and no error/error_description', () => {
it('throws an GenericServerException', async () => {
fetchOnce(
Expand Down
3 changes: 2 additions & 1 deletion src/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ export class WorkOS {
if (response) {
const { status, data, headers } = response;

const requestID = headers['X-Request-ID'] ?? '';
const requestID = headers.get('X-Request-ID') ?? '';

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.

🔴 Request timeouts crash with a TypeError instead of reporting the timeout error

The request-ID header is read using a method call (headers.get('X-Request-ID') at src/workos.ts:400) that only works on native Headers objects, but the timeout error path supplies a plain empty object instead, so any timed-out request throws an unrelated TypeError.

Impact: When any non-retryable API call times out, the SDK crashes with "headers.get is not a function" instead of surfacing the timeout error.

Timeout error path constructs headers as a plain object

In src/common/net/fetch-client.ts:306-313, when a request times out (AbortError), an HttpClientError is thrown with headers: {} — a plain JavaScript object. This error reaches handleHttpError in src/workos.ts:390, where headers.get('X-Request-ID') is called at line 400. Since {} doesn't have a .get() method, this throws TypeError: headers.get is not a function.

The old code used bracket access (headers['X-Request-ID']), which returns undefined on both plain objects and Headers objects, safely falling back to '' via ?? ''.

The fix should either:

  1. Make the timeout path in fetch-client.ts:310 use a real Headers object: headers: new Headers(), or
  2. Use a defensive check in workos.ts:400: typeof headers.get === 'function' ? headers.get('X-Request-ID') : headers['X-Request-ID']
Prompt for agents
The change from headers['X-Request-ID'] to headers.get('X-Request-ID') at src/workos.ts:400 breaks when the headers object is a plain JS object (not a native Headers instance). This happens in the timeout error path at src/common/net/fetch-client.ts:306-313, where HttpClientError is constructed with headers: {} (a plain object without a .get() method).

Two possible fixes:
1. In src/common/net/fetch-client.ts:310, change headers: {} to headers: new Headers() so it always has a .get() method.
2. In src/workos.ts:400, add a defensive check like: const requestID = (typeof headers.get === 'function' ? headers.get('X-Request-ID') : headers['X-Request-ID']) ?? '';

Option 1 is cleaner because it fixes the root cause — the timeout path should produce a proper Headers object consistent with the normal error path.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

Good catch, and confirmed. I reproduced it independently: the AbortError/timeout branch in fetch-client.ts constructs HttpClientError with headers: {}, so once handleHttpError reads the request ID via headers.get('X-Request-ID') the plain object throws TypeError: headers.get is not a function, masking the real timeout error.

Fixed via option 1 — the timeout path now constructs the error with new Headers() (commit a7f1eb1). I chose fixing it at the source over a defensive typeof headers.get === 'function' check in handleHttpError because it keeps a single, uniform header-access path (.get) — the same one already used for Retry-After on 429s — and guarantees every HttpClientError.response.headers is a fetch Headers instance rather than papering over an inconsistent shape at the read site.

Added a WorkOS-level regression test (when the request times out) that drives a timed-out request through WorkOS.post and asserts it rejects with the 408 timeout error (requestID: '') instead of a TypeError. Verified it fails on the pre-fix code with exactly headers.get is not a function and passes after.

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 bracket-to-.get() change also fixes a pre-existing silent bug for real API errors

The old code headers['X-Request-ID'] at src/workos.ts:400 was silently broken for normal (non-timeout) API errors too. In src/common/net/fetch-client.ts:292, the HttpClientError is constructed with headers: res.headers — a native Fetch API Headers object. Native Headers objects do not support bracket notation for reading header values (headers['X-Request-ID'] returns undefined), so the requestID was always '' for all error responses. The new .get() call fixes this, meaning error exceptions will now correctly include the X-Request-ID value. This is a positive behavioral change but could subtly affect error message formatting or logging downstream if anything depended on requestID being empty.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

Correct, and this is intended. Before this change requestID was silently '' on every error thrown from handleHttpError, because headers is a fetch Headers instance (bracket access on it always returns undefined) — surfacing the real request ID is a primary goal of this PR, since support relies on it. The error-only 422 test asserting requestID: 'a-request-id' locks that in (the existing 404 tests use toStrictEqual on Error objects, which ignores custom fields, so they never caught the empty value).

On the downstream-formatting concern: requestID isn't interpolated into any exception message (message is derived from message/error/errors only), so this only populates the previously-empty requestID field. No behavior keyed on it being empty.

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.

P1 Preserve non-fetch headers
handleHttpError also receives locally-created HttpClientErrors, such as the timeout path in FetchHttpClient that sets headers: {} for status 408. Calling headers.get(...) here throws TypeError: headers.get is not a function before the error mapper can produce the intended WorkOS exception, so timed-out requests fail with the wrong error.

Artifacts

Repro: focused Jest timeout repro source

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Stack trace captured during the T-Rex run

  • Keeps the raw stack trace available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos.ts
Line: 400

Comment:
**Preserve non-fetch headers**
`handleHttpError` also receives locally-created `HttpClientError`s, such as the timeout path in `FetchHttpClient` that sets `headers: {}` for status `408`. Calling `headers.get(...)` here throws `TypeError: headers.get is not a function` before the error mapper can produce the intended WorkOS exception, so timed-out requests fail with the wrong error.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Devin Fix in Claude Code

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.

Confirmed and fixed. The timeout path in FetchHttpClient was the one construction site passing headers: {} (a plain object) rather than a fetch Headers instance, so handleHttpError's headers.get('X-Request-ID') threw TypeError: headers.get is not a function before the error mapper could produce the WorkOS exception.

In commit a7f1eb1 the timeout path now uses new Headers(), so all HttpClientErrors carry a real Headers object and handleHttpError reads them uniformly via .get(). Added a WorkOS-level regression test that times out a request through WorkOS.post and asserts it rejects with the 408 timeout error rather than a TypeError (verified it fails on the pre-fix code and passes after). Thanks for the T-Rex repro — it matches mine exactly.

const {
code,
error_description: errorDescription,
Expand All @@ -416,6 +416,7 @@ export class WorkOS {
case 422: {
throw new UnprocessableEntityException({
code,
error,
errors,
message,
requestID,
Expand Down