Skip to content

fix: Surface the error field and request ID on 422 exceptions#1656

Open
mjdavidson wants to merge 2 commits into
mainfrom
fix/forward-422-error-field
Open

fix: Surface the error field and request ID on 422 exceptions#1656
mjdavidson wants to merge 2 commits into
mainfrom
fix/forward-422-error-field

Conversation

@mjdavidson

@mjdavidson mjdavidson commented Jul 14, 2026

Copy link
Copy Markdown

Description

Some WorkOS API 422 responses carry only an error field (for example, Vault's {"error": "Maximum unique keys reached"}). UnprocessableEntityException ignored that field, so applications saw only the generic Unprocessable entity message and had to reproduce the request with raw HTTP to learn the actual reason. ConflictException (409) already forwards error — this brings the 422 path in line with it. Precedence is unchanged: message wins over error, and a structured errors array still takes priority over both.

Also fixes requestID being empty on every exception thrown from handleHttpError: it read the request ID with bracket access (headers['X-Request-ID']) on a fetch Headers instance, which always returns undefined — the same function already uses headers.get('Retry-After') correctly for 429s. Support relies on request IDs, so exceptions now carry them.

Tests cover the new 422 cases (error-only body, message precedence, errors-array precedence) — the error-only case also locks in the requestID fix, since it asserts the value that bracket access silently dropped.

Documentation

[ ] No

Devin shepherd: https://app.devin.ai/sessions/bbb48b72a3544efaa196436ee81ac731

@mjdavidson mjdavidson requested review from a team as code owners July 14, 2026 18:38
@mjdavidson mjdavidson requested a review from awolfden July 14, 2026 18:38

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread src/workos.ts
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.

Comment thread src/workos.ts
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.

🔍 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.

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR updates WorkOS HTTP error mapping for 422 responses and request IDs. The main changes are:

  • Surfaces error-only 422 payloads through UnprocessableEntityException.
  • Preserves precedence where structured errors beat message, and message beats error.
  • Reads X-Request-ID from fetch Headers with headers.get(...).
  • Uses a Headers instance for synthesized timeout responses.
  • Adds tests for 422 precedence, request ID propagation, and timeout handling.

Confidence Score: 5/5

Safe to merge with minimal risk.

The change is small, focused, and covered by tests for the updated 422 and timeout paths.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • Executed the focused Jest command for the WorkOS spec and confirmed it passed with exit code 0.
  • Ran TypeScript type checks and confirmed successful output with exit code 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
src/common/exceptions/unprocessable-entity.exception.ts Adds support for error-only 422 payloads while preserving message and structured errors precedence.
src/common/net/fetch-client.ts Uses a Headers instance for synthesized timeout responses so shared error handling can read headers consistently.
src/workos.spec.ts Adds tests for 422 error/message/errors precedence and timeout handling.
src/workos.ts Reads request IDs through the Headers API and forwards 422 error payloads into UnprocessableEntityException.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant API as WorkOS API / Fetch client
participant Client as WorkOS.handleHttpError
participant U422 as UnprocessableEntityException
participant App as SDK caller

API-->>Client: HttpClientError(status, Headers, data)
Client->>Client: "requestID = headers.get("X-Request-ID") ?? """
alt status is 422
    Client->>U422: code, errors, message, error, requestID
    U422->>U422: "errors > message > error precedence"
    U422-->>App: throw 422 exception with message/requestID
else timeout synthesized locally
    API-->>Client: "status 408, new Headers(), { error }"
    Client-->>App: mapped exception without Headers TypeError
end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant API as WorkOS API / Fetch client
participant Client as WorkOS.handleHttpError
participant U422 as UnprocessableEntityException
participant App as SDK caller

API-->>Client: HttpClientError(status, Headers, data)
Client->>Client: "requestID = headers.get("X-Request-ID") ?? """
alt status is 422
    Client->>U422: code, errors, message, error, requestID
    U422->>U422: "errors > message > error precedence"
    U422-->>App: throw 422 exception with message/requestID
else timeout synthesized locally
    API-->>Client: "status 408, new Headers(), { error }"
    Client-->>App: mapped exception without Headers TypeError
end
Loading

Reviews (2): Last reviewed commit: "fix: Give timeout errors a real Headers ..." | Re-trigger Greptile

Comment thread src/workos.ts
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.

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.

The timeout (408) path in FetchHttpClient constructed HttpClientError with a plain-object headers ({}). After switching handleHttpError to read the request ID with headers.get('X-Request-ID'), that path threw TypeError: headers.get is not a function, masking the timeout with an unrelated crash.

Construct the timeout error with new Headers() so every HttpClientError carries a fetch Headers instance and handleHttpError can read it uniformly. Add a WorkOS-level regression test asserting a timed-out request surfaces the 408 timeout error (not a TypeError).

Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Thanks to both reviewers. The one runtime bug both flagged — the timeout (408) path passing headers: {}, which made handleHttpError's headers.get('X-Request-ID') throw TypeError: headers.get is not a function — is fixed in a7f1eb1 by constructing that path's error with new Headers(), so every HttpClientError now carries a fetch Headers instance and header access stays uniform. Added a WorkOS-level regression test (when the request times out) that verifies a timed-out request surfaces the 408 timeout error rather than a TypeError; confirmed it fails pre-fix and passes after. Full suite (877 passing) and tsc --noEmit are green locally. Replied inline to each specific comment above.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant