Skip to content

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

Open
mjdavidson wants to merge 3 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 3 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 fixes two independent bugs in the error-handling path. First, UnprocessableEntityException now surfaces the error field from 422 responses that carry no message, matching the existing pattern in ConflictException. Second, handleHttpError was reading the request ID with bracket notation (headers['X-Request-ID']) on a fetch Headers instance — which always yields undefined — so every thrown exception carried an empty requestID; the fix uses headers.get(...) consistently.

  • UnprocessableEntityException: error field forwarded with Error: ${error} prefix when message is absent; precedence errors > message > error > default preserved.
  • fetch-client.ts / http-client.ts: Timeout path now passes new Headers() instead of {}, so headers.get('X-Request-ID') no longer throws a TypeError and the type of HttpClientError.response.headers is tightened to Headers.
  • Tests added for the three new 422 paths (error-only, message precedence, errors-array precedence) and for the timeout regression.

Confidence Score: 5/5

Safe to merge — both fixes are narrow and well-targeted, matching existing exception patterns, with regression tests for every changed path.

Changes are small, surgical, and consistent with the patterns already used by ConflictException and OauthException. The headers.get() fix eliminates a silent data-loss bug (empty requestID) on every error response, and the new Headers() fix prevents a TypeError crash on timeout. All new test cases are correct and the overall test coverage is good.

No files require special attention.

Important Files Changed

Filename Overview
src/common/exceptions/unprocessable-entity.exception.ts Adds error parameter and else if (error) branch to surface 422 error-only responses; consistent with ConflictException pattern.
src/workos.ts Fixes headers['X-Request-ID']headers.get('X-Request-ID') for correct fetch Headers access; forwards error field to UnprocessableEntityException.
src/common/net/fetch-client.ts Timeout path now passes new Headers() so handleHttpError can call .get() without throwing TypeError.
src/common/net/http-client.ts Type of response.headers in HttpClientError narrowed from any to Headers; enforces the contract at compile time.
src/workos.spec.ts Well-structured new test cases: error-only 422 (also locks in requestID fix), message precedence, errors-array precedence, and timeout regression guard.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant WorkOS
    participant FetchHttpClient
    participant handleHttpError
    participant UnprocessableEntityException

    App->>WorkOS: "post('/path', {})"
    WorkOS->>FetchHttpClient: request(...)

    alt Timeout
        FetchHttpClient-->>FetchHttpClient: AbortError caught
        FetchHttpClient->>handleHttpError: "HttpClientError(408, new Headers(), {error:'Request timeout'})"
        handleHttpError->>handleHttpError: headers.get('X-Request-ID') → ''
        handleHttpError-->>App: OauthException(408, '', 'Request timeout')
    else 422 error-only response
        FetchHttpClient-->>WorkOS: "HttpClientResponse(422, Headers{X-Request-ID}, {error:'...'})"
        WorkOS->>handleHttpError: HttpClientError(422, headers, data)
        handleHttpError->>handleHttpError: headers.get('X-Request-ID') → requestID
        handleHttpError->>UnprocessableEntityException: "new({error, requestID})"
        UnprocessableEntityException-->>App: "message='Error: ...' + requestID"
    else 422 with message
        FetchHttpClient-->>WorkOS: "HttpClientResponse(422, Headers, {message:'...',error:'...'})"
        WorkOS->>handleHttpError: HttpClientError(422, headers, data)
        handleHttpError->>UnprocessableEntityException: "new({message, error, requestID})"
        Note over UnprocessableEntityException: message wins over error
        UnprocessableEntityException-->>App: "message='...' + requestID"
    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 App
    participant WorkOS
    participant FetchHttpClient
    participant handleHttpError
    participant UnprocessableEntityException

    App->>WorkOS: "post('/path', {})"
    WorkOS->>FetchHttpClient: request(...)

    alt Timeout
        FetchHttpClient-->>FetchHttpClient: AbortError caught
        FetchHttpClient->>handleHttpError: "HttpClientError(408, new Headers(), {error:'Request timeout'})"
        handleHttpError->>handleHttpError: headers.get('X-Request-ID') → ''
        handleHttpError-->>App: OauthException(408, '', 'Request timeout')
    else 422 error-only response
        FetchHttpClient-->>WorkOS: "HttpClientResponse(422, Headers{X-Request-ID}, {error:'...'})"
        WorkOS->>handleHttpError: HttpClientError(422, headers, data)
        handleHttpError->>handleHttpError: headers.get('X-Request-ID') → requestID
        handleHttpError->>UnprocessableEntityException: "new({error, requestID})"
        UnprocessableEntityException-->>App: "message='Error: ...' + requestID"
    else 422 with message
        FetchHttpClient-->>WorkOS: "HttpClientResponse(422, Headers, {message:'...',error:'...'})"
        WorkOS->>handleHttpError: HttpClientError(422, headers, data)
        handleHttpError->>UnprocessableEntityException: "new({message, error, requestID})"
        Note over UnprocessableEntityException: message wins over error
        UnprocessableEntityException-->>App: "message='...' + requestID"
    end
Loading

Reviews (4): Last reviewed commit: "fix: Type HttpClientError response heade..." | Re-trigger Greptile

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

handleHttpError reads the request ID with headers.get(), so every
HttpClientError must carry a fetch Headers instance. The headers field
was typed any, which is how the timeout path compiled while passing a
plain object and crashed at runtime. Typing it as Headers makes the
compiler enforce the invariant at every construction site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants