diff --git a/.gitignore b/.gitignore index ffa108ee..77b96dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ vendor/ /.phpunit.cache .phpunit.result.cache +# PHP CS Fixer +/.php-cs-fixer.cache + # IDEs .idea/ diff --git a/lib/Exception/ApiException.php b/lib/Exception/ApiException.php index 6054edd9..5e81d842 100644 --- a/lib/Exception/ApiException.php +++ b/lib/Exception/ApiException.php @@ -6,9 +6,32 @@ 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|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, @@ -16,15 +39,23 @@ public function __construct( ?\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 $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); } } diff --git a/lib/Exception/RateLimitExceededException.php b/lib/Exception/RateLimitExceededException.php index dd440973..5790e2ea 100644 --- a/lib/Exception/RateLimitExceededException.php +++ b/lib/Exception/RateLimitExceededException.php @@ -6,10 +6,24 @@ 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|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, @@ -17,9 +31,10 @@ public function __construct( ?\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; } } diff --git a/lib/HttpClient.php b/lib/HttpClient.php index 5d7a4823..9da6100e 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -278,19 +278,30 @@ 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, @@ -298,21 +309,34 @@ private function mapApiException(ResponseInterface $response, ?\Throwable $previ $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} */ 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); @@ -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 diff --git a/tests/HttpClientTest.php b/tests/HttpClientTest.php index 34af98a6..1e949913 100644 --- a/tests/HttpClientTest.php +++ b/tests/HttpClientTest.php @@ -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'], + ); } } @@ -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); } }