Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ vendor/
/.phpunit.cache
.phpunit.result.cache

# PHP CS Fixer
/.php-cs-fixer.cache

# IDEs
.idea/

Expand Down
35 changes: 33 additions & 2 deletions lib/Exception/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,56 @@

namespace WorkOS\Exception;

/** @phpstan-consistent-constructor */
/**
* Base exception thrown for any HTTP error response from the WorkOS API.
*
* Subclasses are mapped 1:1 to HTTP status codes (e.g. 400 -> BadRequestException).
* Catch this class to handle all API errors uniformly, or a specific subclass to
* branch on status.
*
* @phpstan-consistent-constructor
*/
class ApiException extends \Exception implements WorkOSException
{
/**
* @param string $message Human-readable error message, sourced from the
* response body's `message` field when present.
* @param int|null $statusCode HTTP status code of the error response.
* @param string|null $requestId Value of the `X-Request-ID` response header,
* if any. Useful when reporting issues to WorkOS support.
* @param \Throwable|null $previous Previous throwable (e.g. the underlying Guzzle exception).
* @param string|null $errorCode Machine-readable code from the response body's `code` field.
* @param string|null $error Short error identifier from the response body's `error` field.
* @param array<string, mixed>|null $rawBody Full decoded JSON error body, or null if the response
* was empty or non-JSON. Lets callers access fields the
* SDK doesn't promote to first-class properties (e.g.
* `pending_authentication_token`, `email`,
* `email_verification_id` from headless AuthKit).
*/
public function __construct(
string $message = '',
public readonly ?int $statusCode = null,
public readonly ?string $requestId = null,
?\Throwable $previous = null,
public readonly ?string $errorCode = null,
public readonly ?string $error = null,
public readonly ?array $rawBody = null,
) {
parent::__construct($message, $statusCode ?? 0, $previous);
}

/**
* Build an exception of the called class from a parsed JSON error response.
*
* @param int $statusCode HTTP status code.
* @param array<string, mixed> $body Decoded JSON response body.
* @param string|null $requestId Value of the `X-Request-ID` header, if any.
*/
public static function fromResponse(int $statusCode, array $body, ?string $requestId = null): static
{
$message = $body['message'] ?? 'Unknown error';
$errorCode = isset($body['code']) && is_string($body['code']) ? $body['code'] : null;
$error = isset($body['error']) && is_string($body['error']) ? $body['error'] : null;
return new static($message, $statusCode, $requestId, null, $errorCode, $error);
return new static($message, $statusCode, $requestId, null, $errorCode, $error, $body);
}
}
17 changes: 16 additions & 1 deletion lib/Exception/RateLimitExceededException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,35 @@

namespace WorkOS\Exception;

/**
* Thrown when the WorkOS API returns HTTP 429 (Too Many Requests).
*
* If the response includes a `Retry-After` header, its parsed value (in seconds)
* is exposed on {@see self::$retryAfter} so callers can implement backoff.
*/
class RateLimitExceededException extends BaseRequestException
{
/**
* Seconds to wait before retrying, parsed from the `Retry-After` response
* header. Null if the header was absent or unparseable.
*/
public ?int $retryAfter = null;

/**
* @param array<string, mixed>|null $rawBody Full decoded JSON error body. See {@see ApiException::__construct}.
* @param int|null $retryAfter Seconds to wait before retrying.
*/
public function __construct(
string $message = '',
?int $statusCode = 429,
?string $requestId = null,
?\Throwable $previous = null,
?string $errorCode = null,
?string $error = null,
?array $rawBody = null,
?int $retryAfter = null,
) {
parent::__construct($message, $statusCode, $requestId, $previous, $errorCode, $error);
parent::__construct($message, $statusCode, $requestId, $previous, $errorCode, $error, $rawBody);
$this->retryAfter = $retryAfter;
}
}
50 changes: 38 additions & 12 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -278,41 +278,65 @@ private function decodeResponse(ResponseInterface $response): ?array
return $decoded;
}

/**
* Map a 4xx/5xx HTTP response to the corresponding {@see ApiException} subclass.
*
* The full decoded JSON body (if any) is threaded through to the exception's
* `$rawBody` property so callers can read fields the SDK doesn't surface as
* dedicated properties (e.g. `pending_authentication_token` from headless AuthKit).
*
* @param ResponseInterface $response The error response.
* @param \Throwable|null $previous Underlying transport exception, if any.
*/
private function mapApiException(ResponseInterface $response, ?\Throwable $previous = null): ApiException
{
$statusCode = $response->getStatusCode();
$requestId = $response->getHeaderLine('X-Request-ID') ?: null;
$body = $this->decodeErrorBody($response);
$rawBody = $body['rawBody'];

return match ($statusCode) {
400 => new BadRequestException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
401 => new AuthenticationException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
403 => new AuthorizationException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
404 => new NotFoundException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
409 => new ConflictException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
422 => new UnprocessableEntityException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
400 => new BadRequestException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
401 => new AuthenticationException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
403 => new AuthorizationException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
404 => new NotFoundException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
409 => new ConflictException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
422 => new UnprocessableEntityException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
429 => new RateLimitExceededException(
$body['message'],
$statusCode,
$requestId,
$previous,
$body['code'],
$body['error'],
$rawBody,
$this->parseRetryAfter($response->getHeaderLine('Retry-After')),
),
500, 502, 503, 504 => new ServerException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
default => new BaseRequestException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error']),
500, 502, 503, 504 => new ServerException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
default => new BaseRequestException($body['message'], $statusCode, $requestId, $previous, $body['code'], $body['error'], $rawBody),
};
}

/**
* @return array{message: string, code: ?string, error: ?string}
* Parse an error response body into the fields used to build an {@see ApiException}.
*
* Falls back to a synthetic message when the body is empty, and treats the
* raw contents as the message when the body isn't valid JSON. The decoded
* body itself (when JSON-shaped) is always returned in `rawBody` for callers
* that need to read additional response fields.
*
* @return array{message: string, code: ?string, error: ?string, rawBody: ?array<string, mixed>}
*/
private function decodeErrorBody(ResponseInterface $response): array
{
$contents = (string) $response->getBody();
if ($contents === '') {
return ['message' => sprintf('WorkOS request failed with status %d.', $response->getStatusCode()), 'code' => null, 'error' => null];
return [
'message' => sprintf('WorkOS request failed with status %d.', $response->getStatusCode()),
'code' => null,
'error' => null,
'rawBody' => null,
];
}

$decoded = json_decode($contents, true);
Expand All @@ -321,11 +345,13 @@ private function decodeErrorBody(ResponseInterface $response): array
if (is_string($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];
return ['message' => $message, 'code' => $code, 'error' => $error, 'rawBody' => $decoded];
}

return ['message' => $contents, 'code' => null, 'error' => null, 'rawBody' => $decoded];
}

return ['message' => $contents, 'code' => null, 'error' => null];
return ['message' => $contents, 'code' => null, 'error' => null, 'rawBody' => null];
}

private function mapTransportException(\Throwable $exception): \Exception
Expand Down
47 changes: 47 additions & 0 deletions tests/HttpClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,52 @@ public function testErrorResponseIncludesCodeAndError(): void
$this->assertSame(400, $e->statusCode);
$this->assertSame('entity_not_found', $e->errorCode);
$this->assertSame('not_found', $e->error);
$this->assertSame(
['message' => 'Organization not found', 'code' => 'entity_not_found', 'error' => 'not_found'],
$e->rawBody,
);
}
}

public function testErrorResponseExposesAdditionalBodyFields(): void
{
// Headless AuthKit returns extra metadata (pending_authentication_token, email, etc.)
// alongside an error. Customers need access to these fields to drive next-step flows.
$body = json_encode([
'message' => 'Email verification required.',
'code' => 'email_verification_required',
'error' => 'email_verification_required',
'error_description' => 'The user must verify their email before signing in.',
'pending_authentication_token' => 'pat_01HXYZ',
'email' => 'user@example.com',
'email_verification_id' => 'email_verification_01HXYZ',
]);

$mock = new MockHandler([
new Response(403, ['Content-Type' => 'application/json'], $body),
]);

$client = new HttpClient(
apiKey: 'test_key',
clientId: null,
baseUrl: 'https://api.workos.com',
timeout: 10,
maxRetries: 0,
handler: HandlerStack::create($mock),
);

try {
$client->request('GET', '/test');
$this->fail('Expected ApiException');
} catch (ApiException $e) {
$this->assertNotNull($e->rawBody);
$this->assertSame('pat_01HXYZ', $e->rawBody['pending_authentication_token']);
$this->assertSame('user@example.com', $e->rawBody['email']);
$this->assertSame('email_verification_01HXYZ', $e->rawBody['email_verification_id']);
$this->assertSame(
'The user must verify their email before signing in.',
$e->rawBody['error_description'],
);
}
}

Expand Down Expand Up @@ -202,6 +248,7 @@ public function testEmptyErrorBodySetsNullCodeAndError(): void
$this->assertStringContainsString('WorkOS request failed with status 500', $e->getMessage());
$this->assertNull($e->errorCode);
$this->assertNull($e->error);
$this->assertNull($e->rawBody);
}
}

Expand Down
Loading