diff --git a/README.md b/README.md index 6546680..ea81106 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ composer require mathiasgrimm/glimpse-php ``` ```php -use GlimpseImg\Client; -use GlimpseImg\ImageFormat; +use MathiasGrimm\GlimpsePhp\Client; +use MathiasGrimm\GlimpsePhp\ImageFormat; use Illuminate\Http\Client\Factory; $glimpse = new Client(new Factory, 'your-api-token'); diff --git a/composer.json b/composer.json index e8badbc..1818bcb 100644 --- a/composer.json +++ b/composer.json @@ -30,12 +30,12 @@ }, "autoload": { "psr-4": { - "GlimpseImg\\": "src/" + "MathiasGrimm\\GlimpsePhp\\": "src/" } }, "autoload-dev": { "psr-4": { - "GlimpseImg\\Tests\\": "tests/" + "MathiasGrimm\\GlimpsePhp\\Tests\\": "tests/" } }, "scripts": { diff --git a/src/ApiException.php b/src/ApiException.php index 9fadcf3..fd634f0 100644 --- a/src/ApiException.php +++ b/src/ApiException.php @@ -1,6 +1,6 @@ status() === 403) { + $message = $response->json('message'); + + throw new ForbiddenException( + is_string($message) && $message !== '' ? $message : 'This token may not call this endpoint.', + ); + } + if ($response->status() === 422) { $message = $response->json('message'); $errors = $response->json('errors'); @@ -148,6 +156,15 @@ private function guard(Response $response): Response ); } + if ($response->status() === 429) { + $message = $response->json('message'); + + throw new RateLimitException( + is_string($message) && $message !== '' ? $message : 'The API rate limit was reached.', + $this->retryAfterSeconds($response), + ); + } + if ($response->failed()) { $message = $response->json('message'); @@ -161,6 +178,29 @@ private function guard(Response $response): Response return $response; } + /** + * Parse the Retry-After header, which RFC 9110 allows as either + * delay-seconds or an HTTP date. Negative and fractional delays are + * clamped so a caller can sleep the value as-is; an absent or + * unparseable header yields null. + */ + private function retryAfterSeconds(Response $response): ?int + { + $header = $response->header('Retry-After'); + + if ($header === '') { + return null; + } + + if (is_numeric($header)) { + return max(0, (int) ceil((float) $header)); + } + + $timestamp = strtotime($header); + + return $timestamp === false ? null : max(0, $timestamp - time()); + } + private function requireToken(): string { $token = $this->token instanceof Closure ? ($this->token)() : $this->token; diff --git a/src/ForbiddenException.php b/src/ForbiddenException.php new file mode 100644 index 0000000..0c27c89 --- /dev/null +++ b/src/ForbiddenException.php @@ -0,0 +1,5 @@ + Factory::response(['message' => 'Invalid ability provided.'], 403)]); + + expect(fn () => client($http)->convert(Images::png(), ImageFormat::Jpg)) + ->toThrow(ForbiddenException::class, 'Invalid ability provided.'); +}); + +test('a 403 response without a message gets the fallback text', function () { + $http = fakeHttp(['*/v1/convert' => Factory::response([], 403)]); + + expect(fn () => client($http)->convert(Images::png(), ImageFormat::Jpg)) + ->toThrow(ForbiddenException::class, 'This token may not call this endpoint.'); +}); + +test('a 429 response maps to RateLimitException carrying Retry-After', function () { + $http = fakeHttp(['*/v1/analyze' => Factory::response(['message' => 'Too Many Requests'], 429, ['Retry-After' => '17'])]); + + try { + client($http)->analyze(ImageFormat::Jpg, 2_500_000); + $this->fail('Expected a RateLimitException.'); + } catch (RateLimitException $e) { + expect($e->getMessage())->toBe('Too Many Requests') + ->and($e->retryAfterSeconds)->toBe(17); + } +}); + +test('a 429 response without a Retry-After header yields a null delay', function () { + $http = fakeHttp(['*/v1/analyze' => Factory::response(['message' => 'Too Many Requests'], 429)]); + + try { + client($http)->analyze(ImageFormat::Jpg, 2_500_000); + $this->fail('Expected a RateLimitException.'); + } catch (RateLimitException $e) { + expect($e->retryAfterSeconds)->toBeNull(); + } +}); + +test('a 429 response prefers the API message over the fallback', function () { + $http = fakeHttp(['*/v1/analyze' => Factory::response(['message' => 'Shared token limit reached.'], 429)]); + + expect(fn () => client($http)->analyze(ImageFormat::Jpg, 2_500_000)) + ->toThrow(RateLimitException::class, 'Shared token limit reached.'); +}); + +test('Retry-After parsing clamps and rounds odd delay values', function (string $header, ?int $expected) { + $http = fakeHttp(['*/v1/analyze' => Factory::response([], 429, ['Retry-After' => $header])]); + + try { + client($http)->analyze(ImageFormat::Jpg, 2_500_000); + $this->fail('Expected a RateLimitException.'); + } catch (RateLimitException $e) { + expect($e->retryAfterSeconds)->toBe($expected); + } +})->with([ + 'negative clamps to zero' => ['-5', 0], + 'fractional rounds up' => ['2.5', 3], + 'garbage yields null' => ['soon', null], +]); + +test('an HTTP-date Retry-After resolves to the remaining seconds', function () { + $http = fakeHttp(['*/v1/analyze' => Factory::response([], 429, [ + 'Retry-After' => gmdate('D, d M Y H:i:s \G\M\T', time() + 30), + ])]); + + try { + client($http)->analyze(ImageFormat::Jpg, 2_500_000); + $this->fail('Expected a RateLimitException.'); + } catch (RateLimitException $e) { + expect($e->retryAfterSeconds)->toBeGreaterThanOrEqual(28) + ->and($e->retryAfterSeconds)->toBeLessThanOrEqual(30); + } +}); + +test('the new exceptions stay catchable as ApiException', function () { + $limited = fakeHttp(['*/v1/analyze' => Factory::response([], 429)]); + $forbidden = fakeHttp(['*/v1/convert' => Factory::response([], 403)]); + + expect(fn () => client($limited)->analyze(ImageFormat::Jpg, 2_500_000))->toThrow(ApiException::class) + ->and(fn () => client($forbidden)->convert(Images::png(), ImageFormat::Jpg))->toThrow(ApiException::class); +}); + test('other failures map to ApiException with the status code', function () { $http = fakeHttp(['*/v1/optimize' => Factory::response(['message' => 'Server Error'], 500)]); diff --git a/tests/Fixtures/Images.php b/tests/Fixtures/Images.php index b620400..e6776db 100644 --- a/tests/Fixtures/Images.php +++ b/tests/Fixtures/Images.php @@ -1,6 +1,6 @@ count(Images::animatedGif()))->toBe(3); diff --git a/tests/ImageFormatTest.php b/tests/ImageFormatTest.php index 3a72f37..0753b10 100644 --- a/tests/ImageFormatTest.php +++ b/tests/ImageFormatTest.php @@ -1,7 +1,7 @@ toBe(ImageFormat::Jpg) diff --git a/tests/Pest.php b/tests/Pest.php index d7a1b24..ccab4f9 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,6 @@