From 908dd642cb9c24eeba0c155147c3a90d9d64b9bd Mon Sep 17 00:00:00 2001 From: Jason Holderness Date: Tue, 21 Apr 2026 08:35:27 -0700 Subject: [PATCH] feat: throw typed LeverApiException preserving HTTP status and response body Callers could previously only catch a generic RuntimeException with no way to distinguish transient 5xx from permanent 4xx errors, or to read the Lever error payload. Introduce LeverApiException (extends RuntimeException for backward compatibility) that carries statusCode, responseBody, and the original Guzzle exception as the previous. Also calls parent::setUp() in the test case so the Log facade is available during request error paths. --- src/Http/Client/LeverClient.php | 19 ++++++++++-- src/Http/Exceptions/LeverApiException.php | 38 +++++++++++++++++++++++ tests/OpportunitiesTest.php | 19 ++++++++++++ tests/TestCase.php | 2 ++ 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/Http/Exceptions/LeverApiException.php diff --git a/src/Http/Client/LeverClient.php b/src/Http/Client/LeverClient.php index 83703d6..553727e 100644 --- a/src/Http/Client/LeverClient.php +++ b/src/Http/Client/LeverClient.php @@ -2,6 +2,7 @@ namespace Bluelightco\LeverPhp\Http\Client; +use Bluelightco\LeverPhp\Http\Exceptions\LeverApiException; use Bluelightco\LeverPhp\Http\Middleware\LeverRateStore; use Bluelightco\LeverPhp\Http\Middleware\QueryStringCleanerMiddleware; use Bluelightco\LeverPhp\Http\Responses\ApiResponse; @@ -14,7 +15,6 @@ use Illuminate\Support\LazyCollection; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; -use RuntimeException; use Spatie\GuzzleRateLimiterMiddleware\RateLimiterMiddleware; use Spatie\GuzzleRateLimiterMiddleware\Store; @@ -470,6 +470,14 @@ private function checkExpandOptions(array|string $expand): bool private function handleException(Exception $e, string $method, string $endpoint, array $options = []): void { + $statusCode = null; + $responseBody = null; + + if ($e instanceof ClientException && $e->getResponse()) { + $statusCode = $e->getResponse()->getStatusCode(); + $responseBody = (string) $e->getResponse()->getBody(); + } + Log::error("HTTP $method error: ".$e->getMessage(), [ 'message' => $e->getMessage(), 'package_context' => [ @@ -477,11 +485,16 @@ private function handleException(Exception $e, string $method, string $endpoint, 'method' => $method, 'endpoint' => $endpoint, 'options' => json_encode($options), - 'response' => ($e instanceof ClientException ? ($e->getResponse() ? $e->getResponse()->getBody()->getContents() : null) : null), + 'response' => $responseBody, ], 'exception' => $e, ]); - throw new RuntimeException("Error executing HTTP $method. Please check the logs for more details."); + throw new LeverApiException( + "Error executing HTTP $method. Please check the logs for more details.", + $statusCode, + $responseBody, + $e, + ); } } diff --git a/src/Http/Exceptions/LeverApiException.php b/src/Http/Exceptions/LeverApiException.php new file mode 100644 index 0000000..643a2f5 --- /dev/null +++ b/src/Http/Exceptions/LeverApiException.php @@ -0,0 +1,38 @@ +statusCode; + } + + public function getResponseBody(): ?string + { + return $this->responseBody; + } + + public function isClientError(): bool + { + return $this->statusCode !== null && $this->statusCode >= 400 && $this->statusCode < 500; + } + + public function isServerError(): bool + { + return $this->statusCode !== null && $this->statusCode >= 500 && $this->statusCode < 600; + } +} diff --git a/tests/OpportunitiesTest.php b/tests/OpportunitiesTest.php index daee78a..97ec773 100644 --- a/tests/OpportunitiesTest.php +++ b/tests/OpportunitiesTest.php @@ -2,6 +2,7 @@ namespace Bluelightco\LeverPhp\Tests; +use Bluelightco\LeverPhp\Http\Exceptions\LeverApiException; use GuzzleHttp\Psr7\Response; use Illuminate\Support\LazyCollection; use RuntimeException; @@ -129,6 +130,24 @@ public function fail_to_create_opportunity_when_no_perform_as_parameter_included ); } + /** @test */ + public function client_error_exposes_status_and_response_body() + { + $body = '{"code":"BadRequestError","message":"Input HTML is not valid."}'; + $this->mockHandler->append(new Response(400, [], $body)); + + try { + $this->lever->opportunities()->create(['name' => 'x']); + $this->fail('Expected LeverApiException was not thrown.'); + } catch (LeverApiException $e) { + $this->assertSame(400, $e->getStatusCode()); + $this->assertSame($body, $e->getResponseBody()); + $this->assertTrue($e->isClientError()); + $this->assertFalse($e->isServerError()); + $this->assertInstanceOf(\GuzzleHttp\Exception\ClientException::class, $e->getPrevious()); + } + } + /** @test */ public function fetching_offers() { diff --git a/tests/TestCase.php b/tests/TestCase.php index 5326e34..88f15c1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -26,6 +26,8 @@ class TestCase extends \Orchestra\Testbench\TestCase protected function setUp(): void { + parent::setUp(); + $this->mockHandler = new MockHandler(); $this->container = [];