Skip to content

feat: surface error metadata from API responses#369

Merged
gjtorikian merged 3 commits intomainfrom
update-error-handling
Apr 24, 2026
Merged

feat: surface error metadata from API responses#369
gjtorikian merged 3 commits intomainfrom
update-error-handling

Conversation

@gjtorikian
Copy link
Copy Markdown
Contributor

Summary

  • Add errorCode and error readonly properties to ApiException
    and propagate them through RateLimitExceededException
  • Update HttpClient::decodeErrorBody() to extract code and
    error from JSON error responses alongside the existing message
  • Pass these fields through to all exception constructors in the
    HTTP status code match expression

This lets SDK consumers distinguish error types programmatically
(e.g. validation codes, rate-limit reasons) without parsing the
human-readable message string.

Test plan

  • Verify exceptions include errorCode and error when API
    returns them in the response body
  • Verify errorCode and error are null when the API
    response omits those fields
  • Verify RateLimitExceededException still correctly captures
    the Retry-After header alongside the new fields

API error responses can include `code` and `error` fields beyond
the message, but these were being discarded. Exposing them on
exception objects lets consumers distinguish error types
programmatically (e.g. rate-limit reason, validation code)
without parsing the message string.
@gjtorikian gjtorikian requested review from a team as code owners April 24, 2026 16:56
@gjtorikian gjtorikian requested a review from mthadley April 24, 2026 16:56
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 24, 2026

Greptile Summary

This PR surfaces errorCode and error metadata from WorkOS API error responses by adding two readonly properties to ApiException, updating decodeErrorBody() to extract them with is_string guards, and threading them through every exception constructor in mapApiException(). The implementation is consistent across all code paths, all new fields default to null when absent, and the five new tests cover the key scenarios (populated fields, null fields, rate-limit, empty body, non-string types).

Confidence Score: 5/5

Safe to merge — no logic errors, type-safety is maintained, and coverage is solid.

All four changed files are internally consistent: decodeErrorBody() always returns both new keys, every exception constructor receives them, and is_string guards protect both entry points (fromResponse and the HTTP client path). No P0 or P1 issues found.

No files require special attention.

Important Files Changed

Filename Overview
lib/Exception/ApiException.php Adds errorCode and error readonly promoted constructor properties; fromResponse() extracts them with is_string guards
lib/Exception/RateLimitExceededException.php Inserts errorCode and error before retryAfter in the constructor; threads them to parent; only call-site is internal HttpClient
lib/HttpClient.php decodeErrorBody() now always returns code/?string and error/?string; all mapApiException() branches pass them through correctly
tests/HttpClientTest.php Five new integration-style tests cover normal, null, rate-limit, empty-body, and non-string-type field scenarios

Sequence Diagram

sequenceDiagram
    participant API as WorkOS API
    participant HC as HttpClient
    participant DE as decodeErrorBody()
    participant MA as mapApiException()
    participant EX as Exception (ApiException subclass)

    API->>HC: HTTP error response (4xx/5xx)
    HC->>DE: ResponseInterface
    DE->>DE: Parse body JSON
    DE-->>HC: {message, code: ?string, error: ?string}
    HC->>MA: response + body
    MA->>MA: match statusCode
    MA->>EX: new XxxException(message, statusCode, requestId, previous, code, error)
    Note over EX: errorCode = code<br/>error = error<br/>retryAfter (429 only)
    EX-->>HC: ApiException subclass
    HC-->>API: throw exception
Loading

Reviews (3): Last reviewed commit: "test: Add coverage for errorCode and err..." | Re-trigger Greptile

Comment thread lib/Exception/ApiException.php Outdated
Comment thread lib/HttpClient.php
Comment on lines 320 to +324
$message = $decoded['message'] ?? $decoded['error_description'] ?? $decoded['error'] ?? null;
if (is_string($message) && $message !== '') {
return ['message' => $message];
$code = isset($decoded['code']) && is_string($decoded['code']) ? $decoded['code'] : null;
$error = isset($decoded['error']) && is_string($decoded['error']) ? $decoded['error'] : null;
return ['message' => $message, 'code' => $code, 'error' => $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.

P2 error field populated when it also serves as message fallback

When a response contains only {"error": "invalid_grant"} (no message or error_description), the fallback at line 320 assigns $decoded['error'] to $message, and line 323 assigns the same value to $error. The resulting exception will have $exception->getMessage() === $exception->error, which offers no additional machine-readable signal over the message string. Callers expecting error to be a distinct, stable code may be misled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair observation, but fine to leave as-is. When the API returns only {"error": "invalid_grant"}, both getMessage() and ->error will be "invalid_grant". That's redundant but accurate — it faithfully reflects what the API sent. The machine-readable signal consumers should key on is errorCode (from the code field), and setting error to null in the fallback case would silently discard data the API actually returned.

`decodeErrorBody()` validates that `code` and `error` are
strings before assigning, but `fromResponse()` did not.
Under `strict_types=1`, a non-string value would throw
a `TypeError`.
Comment on lines 17 to 21
?\Throwable $previous = null,
?string $errorCode = null,
?string $error = null,
?int $retryAfter = null,
) {
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 Breaking constructor change — $retryAfter shifted by two positions

$errorCode and $error are inserted before $retryAfter, changing its positional index from 5 to 7. Any caller that previously passed a $retryAfter value positionally — e.g. new RateLimitExceededException('msg', 429, $rid, null, 30) — will now silently pass 30 to $errorCode (?string). Under declare(strict_types=1) this throws a TypeError at runtime. Appending the new parameters after $retryAfter (or making them named-only via promotion at the end) would keep the signature backward-compatible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The only call site is internal (HttpClient::mapApiException()), which already passes args in the new order. No end user should be constructing SDK exception classes directly — they're thrown by the HTTP client, not instantiated by consumers. Not a practical concern.

The new properties added in feat/66fba13 had no test coverage.
These tests exercise the full path through decodeErrorBody()
and mapApiException() for each scenario: fields present, fields
absent, empty body, rate-limit with Retry-After, and non-string
values rejected by the is_string() guards.
@gjtorikian gjtorikian merged commit ece118b into main Apr 24, 2026
8 checks passed
@gjtorikian gjtorikian deleted the update-error-handling branch April 24, 2026 22:02
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