From 28a41721822154d9dc5a66415e67d469c6b63e23 Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Fri, 20 Feb 2026 09:00:18 +0200 Subject: [PATCH 1/6] Add ProviderUnavailableException for transient provider errors --- .../ProviderUnavailableException.php | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/Common/Exception/ProviderUnavailableException.php diff --git a/src/Common/Exception/ProviderUnavailableException.php b/src/Common/Exception/ProviderUnavailableException.php new file mode 100644 index 00000000..b0f7dd9f --- /dev/null +++ b/src/Common/Exception/ProviderUnavailableException.php @@ -0,0 +1,82 @@ +httpStatusCode = $httpStatusCode; + $this->errorType = $errorType; + } + + /** + * Returns the HTTP status code returned by the provider, if known. + * + * @since n.e.x.t + * + * @return int|null The HTTP status code, or null if not provided. + */ + public function getHttpStatusCode(): ?int + { + return $this->httpStatusCode; + } + + /** + * Returns the error type returned by the provider API, if known. + * + * @since n.e.x.t + * + * @return string|null The error type (e.g. "overloaded_error"), or null if not provided. + */ + public function getErrorType(): ?string + { + return $this->errorType; + } +} \ No newline at end of file From dc4d8a5e0edb6bcf53235152e1a1dde65bb313ac Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Fri, 20 Feb 2026 09:00:51 +0200 Subject: [PATCH 2/6] Add unit tests for ProviderUnavailableException --- tests/unit/Exceptions/ExceptionsTest.php | 42 +++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index fe3b88b0..e247ef01 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -8,6 +8,7 @@ use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Common\Exception\ProviderUnavailableException; use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\NetworkException; @@ -18,6 +19,7 @@ * @since 0.2.0 * @covers \WordPress\AiClient\Common\Exception\InvalidArgumentException * @covers \WordPress\AiClient\Common\Exception\RuntimeException + * @covers \WordPress\AiClient\Common\Exception\ProviderUnavailableException * @covers \WordPress\AiClient\Common\Exception\TokenLimitReachedException * @covers \WordPress\AiClient\Providers\Http\Exception\NetworkException * @covers \WordPress\AiClient\Providers\Http\Exception\ClientException @@ -30,12 +32,13 @@ public function testAllExceptionsImplementAiClientExceptionInterface(): void new InvalidArgumentException('test'), new RuntimeException('test'), new TokenLimitReachedException('test'), + new ProviderUnavailableException('test'), new NetworkException('test'), new ClientException('test'), ]; foreach ($exceptions as $exception) { - $this->assertInstanceOf(AiClientExceptionInterface::class, $exception); + $this->assertInstanceOf(AiClientExceptionInterface::class , $exception); } } @@ -43,7 +46,7 @@ public function testTokenLimitReachedExceptionExtendsRuntimeException(): void { $exception = new TokenLimitReachedException('token limit reached'); - $this->assertInstanceOf(RuntimeException::class, $exception); + $this->assertInstanceOf(RuntimeException::class , $exception); } public function testTokenLimitReachedExceptionMaxTokensDefaultsToNull(): void @@ -60,12 +63,42 @@ public function testTokenLimitReachedExceptionStoresMaxTokens(): void $this->assertSame(4096, $exception->getMaxTokens()); } + public function testProviderUnavailableExceptionExtendsRuntimeException(): void + { + $exception = new ProviderUnavailableException('provider unavailable'); + + $this->assertInstanceOf(RuntimeException::class , $exception); + } + + public function testProviderUnavailableExceptionDefaultsToNull(): void + { + $exception = new ProviderUnavailableException('provider unavailable'); + + $this->assertNull($exception->getHttpStatusCode()); + $this->assertNull($exception->getErrorType()); + } + + public function testProviderUnavailableExceptionStoresHttpStatusCode(): void + { + $exception = new ProviderUnavailableException('provider unavailable', 529); + + $this->assertSame(529, $exception->getHttpStatusCode()); + } + + public function testProviderUnavailableExceptionStoresErrorType(): void + { + $exception = new ProviderUnavailableException('provider unavailable', 529, 'overloaded_error'); + + $this->assertSame('overloaded_error', $exception->getErrorType()); + } + public function testCatchAllFunctionality(): void { $exceptions = [ new InvalidArgumentException('invalid error'), new RuntimeException('runtime error'), new TokenLimitReachedException('token limit error'), + new ProviderUnavailableException('provider unavailable error'), new NetworkException('network error'), new ClientException('client error'), ]; @@ -74,11 +107,12 @@ public function testCatchAllFunctionality(): void $caught = false; try { throw $exception; - } catch (AiClientExceptionInterface $e) { + } + catch (AiClientExceptionInterface $e) { $caught = true; $this->assertStringContainsString('error', $e->getMessage()); } $this->assertTrue($caught, 'Exception should be catchable as AiClientExceptionInterface'); } } -} +} \ No newline at end of file From 95320a894589bac92929640f0b29fa7cad9969e2 Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Fri, 20 Feb 2026 09:11:14 +0200 Subject: [PATCH 3/6] Code linting --- .../Exception/ProviderUnavailableException.php | 5 ++--- tests/unit/Exceptions/ExceptionsTest.php | 13 ++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Common/Exception/ProviderUnavailableException.php b/src/Common/Exception/ProviderUnavailableException.php index b0f7dd9f..4cdf1f56 100644 --- a/src/Common/Exception/ProviderUnavailableException.php +++ b/src/Common/Exception/ProviderUnavailableException.php @@ -48,8 +48,7 @@ public function __construct( ?int $httpStatusCode = null, ?string $errorType = null, ?\Throwable $previous = null - ) - { + ) { parent::__construct($message, 0, $previous); $this->httpStatusCode = $httpStatusCode; @@ -79,4 +78,4 @@ public function getErrorType(): ?string { return $this->errorType; } -} \ No newline at end of file +} diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index e247ef01..24753445 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Common\Exception\ProviderUnavailableException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\NetworkException; @@ -38,7 +38,7 @@ public function testAllExceptionsImplementAiClientExceptionInterface(): void ]; foreach ($exceptions as $exception) { - $this->assertInstanceOf(AiClientExceptionInterface::class , $exception); + $this->assertInstanceOf(AiClientExceptionInterface::class, $exception); } } @@ -46,7 +46,7 @@ public function testTokenLimitReachedExceptionExtendsRuntimeException(): void { $exception = new TokenLimitReachedException('token limit reached'); - $this->assertInstanceOf(RuntimeException::class , $exception); + $this->assertInstanceOf(RuntimeException::class, $exception); } public function testTokenLimitReachedExceptionMaxTokensDefaultsToNull(): void @@ -67,7 +67,7 @@ public function testProviderUnavailableExceptionExtendsRuntimeException(): void { $exception = new ProviderUnavailableException('provider unavailable'); - $this->assertInstanceOf(RuntimeException::class , $exception); + $this->assertInstanceOf(RuntimeException::class, $exception); } public function testProviderUnavailableExceptionDefaultsToNull(): void @@ -107,12 +107,11 @@ public function testCatchAllFunctionality(): void $caught = false; try { throw $exception; - } - catch (AiClientExceptionInterface $e) { + } catch (AiClientExceptionInterface $e) { $caught = true; $this->assertStringContainsString('error', $e->getMessage()); } $this->assertTrue($caught, 'Exception should be catchable as AiClientExceptionInterface'); } } -} \ No newline at end of file +} From 64d4abd091ea45b19eaec5bca4f2dd37837093bf Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Wed, 4 Mar 2026 21:30:44 +0200 Subject: [PATCH 4/6] Add message for error 529 --- .../ProviderUnavailableException.php | 81 ------------------- .../Http/Exception/ServerException.php | 1 + 2 files changed, 1 insertion(+), 81 deletions(-) delete mode 100644 src/Common/Exception/ProviderUnavailableException.php diff --git a/src/Common/Exception/ProviderUnavailableException.php b/src/Common/Exception/ProviderUnavailableException.php deleted file mode 100644 index 4cdf1f56..00000000 --- a/src/Common/Exception/ProviderUnavailableException.php +++ /dev/null @@ -1,81 +0,0 @@ -httpStatusCode = $httpStatusCode; - $this->errorType = $errorType; - } - - /** - * Returns the HTTP status code returned by the provider, if known. - * - * @since n.e.x.t - * - * @return int|null The HTTP status code, or null if not provided. - */ - public function getHttpStatusCode(): ?int - { - return $this->httpStatusCode; - } - - /** - * Returns the error type returned by the provider API, if known. - * - * @since n.e.x.t - * - * @return string|null The error type (e.g. "overloaded_error"), or null if not provided. - */ - public function getErrorType(): ?string - { - return $this->errorType; - } -} diff --git a/src/Providers/Http/Exception/ServerException.php b/src/Providers/Http/Exception/ServerException.php index b366c719..bc4b63de 100644 --- a/src/Providers/Http/Exception/ServerException.php +++ b/src/Providers/Http/Exception/ServerException.php @@ -38,6 +38,7 @@ public static function fromServerErrorResponse(Response $response): self 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage', + 529 => 'Overloaded', ]; if (isset($statusTexts[$statusCode])) { From af369c6431cf6af86574f625ea9b39e6bcd75678 Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Wed, 4 Mar 2026 21:31:31 +0200 Subject: [PATCH 5/6] Add unit tests for server exceptions --- tests/unit/Exceptions/ExceptionsTest.php | 136 +++++++++++++++++------ 1 file changed, 103 insertions(+), 33 deletions(-) diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index 24753445..13128903 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -7,11 +7,12 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Common\Exception\ProviderUnavailableException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\NetworkException; +use WordPress\AiClient\Providers\Http\Exception\ServerException; +use WordPress\AiClient\Providers\Http\DTO\Response; /** * Tests for AI Client exceptions. @@ -19,10 +20,10 @@ * @since 0.2.0 * @covers \WordPress\AiClient\Common\Exception\InvalidArgumentException * @covers \WordPress\AiClient\Common\Exception\RuntimeException - * @covers \WordPress\AiClient\Common\Exception\ProviderUnavailableException * @covers \WordPress\AiClient\Common\Exception\TokenLimitReachedException * @covers \WordPress\AiClient\Providers\Http\Exception\NetworkException * @covers \WordPress\AiClient\Providers\Http\Exception\ClientException + * @covers \WordPress\AiClient\Providers\Http\Exception\ServerException */ class ExceptionsTest extends TestCase { @@ -32,9 +33,9 @@ public function testAllExceptionsImplementAiClientExceptionInterface(): void new InvalidArgumentException('test'), new RuntimeException('test'), new TokenLimitReachedException('test'), - new ProviderUnavailableException('test'), new NetworkException('test'), new ClientException('test'), + new ServerException('test'), ]; foreach ($exceptions as $exception) { @@ -63,44 +64,15 @@ public function testTokenLimitReachedExceptionStoresMaxTokens(): void $this->assertSame(4096, $exception->getMaxTokens()); } - public function testProviderUnavailableExceptionExtendsRuntimeException(): void - { - $exception = new ProviderUnavailableException('provider unavailable'); - - $this->assertInstanceOf(RuntimeException::class, $exception); - } - - public function testProviderUnavailableExceptionDefaultsToNull(): void - { - $exception = new ProviderUnavailableException('provider unavailable'); - - $this->assertNull($exception->getHttpStatusCode()); - $this->assertNull($exception->getErrorType()); - } - - public function testProviderUnavailableExceptionStoresHttpStatusCode(): void - { - $exception = new ProviderUnavailableException('provider unavailable', 529); - - $this->assertSame(529, $exception->getHttpStatusCode()); - } - - public function testProviderUnavailableExceptionStoresErrorType(): void - { - $exception = new ProviderUnavailableException('provider unavailable', 529, 'overloaded_error'); - - $this->assertSame('overloaded_error', $exception->getErrorType()); - } - public function testCatchAllFunctionality(): void { $exceptions = [ new InvalidArgumentException('invalid error'), new RuntimeException('runtime error'), new TokenLimitReachedException('token limit error'), - new ProviderUnavailableException('provider unavailable error'), new NetworkException('network error'), new ClientException('client error'), + new ServerException('server error'), ]; foreach ($exceptions as $exception) { @@ -114,4 +86,102 @@ public function testCatchAllFunctionality(): void $this->assertTrue($caught, 'Exception should be catchable as AiClientExceptionInterface'); } } + + public function testServerExceptionExtendsRuntimeException(): void + { + $exception = new ServerException('server error'); + + $this->assertInstanceOf(RuntimeException::class, $exception); + } + + /** + * @dataProvider knownServerStatusCodeProvider + */ + public function testServerExceptionFromKnownStatusCodes(int $statusCode, string $expectedText): void + { + $response = new Response($statusCode, []); + + $exception = ServerException::fromServerErrorResponse($response); + + $this->assertSame($statusCode, $exception->getCode()); + $this->assertStringContainsString($expectedText, $exception->getMessage()); + $this->assertStringContainsString((string) $statusCode, $exception->getMessage()); + } + + /** + * @return array + */ + public static function knownServerStatusCodeProvider(): array + { + return [ + '500 Internal Server Error' => [500, 'Internal Server Error'], + '502 Bad Gateway' => [502, 'Bad Gateway'], + '503 Service Unavailable' => [503, 'Service Unavailable'], + '504 Gateway Timeout' => [504, 'Gateway Timeout'], + '507 Insufficient Storage' => [507, 'Insufficient Storage'], + '529 Overloaded' => [529, 'Overloaded'], + ]; + } + + public function testServerExceptionFromUnknownStatusCode(): void + { + $response = new Response(599, []); + + $exception = ServerException::fromServerErrorResponse($response); + + $this->assertSame(599, $exception->getCode()); + $this->assertStringContainsString('Server error (599)', $exception->getMessage()); + $this->assertStringContainsString('server-side issue', $exception->getMessage()); + } + + public function testServerExceptionExtractsErrorMessageFromResponseBody(): void + { + $body = json_encode(['error' => ['message' => 'The server is overloaded']]); + $response = new Response(500, [], $body); + + $exception = ServerException::fromServerErrorResponse($response); + + $this->assertStringContainsString('Internal Server Error (500)', $exception->getMessage()); + $this->assertStringContainsString('The server is overloaded', $exception->getMessage()); + } + + public function testServerExceptionExtractsStringErrorFromResponseBody(): void + { + $body = json_encode(['error' => 'Something went wrong']); + $response = new Response(502, [], $body); + + $exception = ServerException::fromServerErrorResponse($response); + + $this->assertStringContainsString('Bad Gateway (502)', $exception->getMessage()); + $this->assertStringContainsString('Something went wrong', $exception->getMessage()); + } + + public function testServerExceptionExtractsMessageKeyFromResponseBody(): void + { + $body = json_encode(['message' => 'Service temporarily unavailable']); + $response = new Response(503, [], $body); + + $exception = ServerException::fromServerErrorResponse($response); + + $this->assertStringContainsString('Service Unavailable (503)', $exception->getMessage()); + $this->assertStringContainsString('Service temporarily unavailable', $exception->getMessage()); + } + + public function testServerExceptionWithNoBody(): void + { + $response = new Response(500, []); + + $exception = ServerException::fromServerErrorResponse($response); + + $this->assertSame('Internal Server Error (500)', $exception->getMessage()); + } + + public function testServerExceptionWithNonJsonBody(): void + { + $response = new Response(500, [], 'Server Error'); + + $exception = ServerException::fromServerErrorResponse($response); + + $this->assertSame('Internal Server Error (500)', $exception->getMessage()); + } } From 9aee1c537b2e70ffeabef7e55c447468a08839d0 Mon Sep 17 00:00:00 2001 From: Lauri Saarni Date: Wed, 4 Mar 2026 21:37:22 +0200 Subject: [PATCH 6/6] Fix linting --- tests/unit/Exceptions/ExceptionsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index 13128903..8dd5315c 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -9,10 +9,10 @@ use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Common\Exception\TokenLimitReachedException; +use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\NetworkException; use WordPress\AiClient\Providers\Http\Exception\ServerException; -use WordPress\AiClient\Providers\Http\DTO\Response; /** * Tests for AI Client exceptions.