Learn how to create, throw, and handle custom exceptions when making requests to third-party APIs
This is the fourth part of my series on Integrating Third-Party APIs in Laravel. If you haven’t been following along, I highly recommend checking out the previous posts to gain the necessary context for a better understanding of this post.
In the previous posts of the series, we learned how to build the following:
- Simple API client class using Laravel’s built-in
Http
facade - Custom request class to generate API requests with a URL, method type, data, query strings, etc.
- API resources to use CRUD-like methods to fetch resources from the APIs
- Custom DTOs with simple classes
- DTOs using the Spatie Laravel-data package
Throughout all of these posts, we also learned how to test these various integrations using the Http
facade and fake responses. However, we only tested best-case scenarios and didn’t go into error handling. So that’s what I hope to accomplish in this post.
Creating a Custom Exception
To get started, I recommend creating a custom exception. For now, we can call it ApiException
. Either create this manually or use the Artisan command:
php artisan make:exception ApiException
Our new exception class can extend the Exception
class and take three parameters.
<?php
namespace App\Exceptions;
use App\Support\ApiRequest;
use Exception;
use Illuminate\Http\Client\Response;
use Throwable;
class ApiException extends Exception
{
public function __construct(
public readonly ?ApiRequest $request = null,
public readonly ?Response $response = null,
Throwable $previous = null,
) {
// Typically, we will just pass in the message from the previous exception, but provide a default if for some reason we threw this exception without a previous one.
$message = $previous?->getMessage() ?: 'An error occurred making an API request';
parent::__construct(
message: $message,
code: $previous?->getCode(),
previous: $previous,
);
}
public function context(): array
{
return [
'uri' => $this->request?->getUri(),
'method' => $this->request?->getMethod(),
];
}
}
The constructor takes a $request
property, $response
property, and a $previous
property.
The $request
property is an instance of the ApiRequest
class we created in a previous post to store information like URL, method, body data, query strings, etc.
The $response
is an instance of Laravel’s default Illuminate\Http\Client\Response
class which is returned when using the Http
facade to make a request. By adding the response to the exception, we can gather a lot more information if needed when handing the exception, like an error object from the third-party API.
Finally, using the previous exception, if it exists, we throw the ApiException
using data from the previous exception or simple defaults.
I also added a context class to provide a little more information which is pulled from the $request
property. Depending on your application, data and query parameters could include sensitive information, so be sure you understand what is being added. For some applications, the URL itself could be sensitive, so adjust as needed or make a context parameter and pass in whatever data works for you.
Throwing the ApiException
Now that we have our new exception class, let’s look at actually throwing it when there is an error. We can update the ApiClient
class from the previous posts to now catch exceptions, use the ApiException
as a wrapper, and include information about the request.
<?php
namespace App\Support;
use App\Exceptions\ApiException;
use Exception;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
abstract class ApiClient
{
/**
* Send an ApiRequest to the API and return the response.
* @throws ApiException
*/
public function send(ApiRequest $request): Response
{
try {
return $this->getBaseRequest()
->withHeaders($request->getHeaders())
->{$request->getMethod()->value}(
$request->getUri(),
$request->getMethod() === HttpMethod::GET
? $request->getQuery()
: $request->getBody()
);
} catch (Exception $exception) {
// Create our new exception and throw it.
throw new ApiException(
request: $request,
response: $exception?->response,
previous: $exception,
);
}
}
protected function getBaseRequest(): PendingRequest
{
$request = Http::acceptJson()
->contentType('application/json')
->throw()
->baseUrl($this->baseUrl());
return $this->authorize($request);
}
protected function authorize(PendingRequest $request): PendingRequest
{
return $request;
}
abstract protected function baseUrl(): string;
}
To throw the exception, all we need to do is wrap the return
in our send
method with a try…catch
and then throw our new exception. When setting the $response
in our exception, we attempt to pull it from the caught exception’s response
property. If our request was made but failed during the process, the Http
facade will throw an Illuminate\Http\Client\RequestException
which has a response
property that is an instance of Illuminate\Http\Client\Response
. If a different exception is caught, we will just set the response to null
.
Testing
Testing the Client
To test our new exception, we’ll create an ApiClientTest.php
file and add the following test.
it('throws an api exception', function () {
// Arrange
Http::fakeSequence()->pushStatus(500);
$request = ApiRequest::get('foo');
// Act
$this->client->send($request);
})->throws(ApiException::class, exceptionCode: 500);
This test uses the Http::fakeSequence()
call and pushes a response with a 500 status code. Then, we expect the client to throw an ApiException
with a 500 exception code.
You might notice that this test fails. This occurs because we used Http::fake()
in the beforeEach
method of the test.
beforeEach(function () {
Http::fake();
$this->client = new class extends ApiClient {
protected function baseUrl(): string
{
return 'https://example.com';
}
};
});
When calling Http::fake()
essentially, we tell it to fake any request made with the facade. It does this by pushing an entry into an internal collection. Even when we add additional items to Http::fake()
or our Http::fakeSequence()
, the fake response will still pull from the first item in the collection since we didn’t specify a specific URL. It works kind of like the router where it finds the first viable route that can be used to fake the response.
To solve this, we can either move Http::fake()
into the various tests themselves. However, I like another approach, which is adding a macro to the Http
facade to be able to reset the internal collection, which is named stubCallbacks
. To do that, open your AppServiceProvider
and add the macro in the boot
method.
// AppServiceProvider
public function boot(): void
{
Http::macro('resetStubs', fn () => $this->stubCallbacks = collect());
}
Now, instead of having to add Http::fake()
to all of our previous tests, we can update our new test to call Http::resetStubs
.
it('throws an api exception', function () {
// Arrange
Http::resetStubs();
Http::fakeSequence()->pushStatus(500);
$request = ApiRequest::get('foo');
// Act
$this->client->send($request);
})->throws(ApiException::class, exceptionCode: 500);
Testing the Exception
Now that we tested that our client throws the API exception, let’s add some tests for the exception itself.
<?php
use App\Exceptions\ApiException;
use App\Support\ApiRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
it('sets default message and code', function () {
// Act
$apiException = new ApiException();
// Assert
expect($apiException)
->getMessage()->toBe('An error occurred making an API request.')
->getCode()->toBe(0);
});
it('sets context based on request', function () {
// Arrange
$request = ApiRequest::get(fake()->url);
// Act
$apiException = new ApiException($request);
// Assert
expect($apiException)->context()->toBe([
'uri' => $request->getUri(),
'method' => $request->getMethod(),
]);
});
it('gets response from RequestException', function () {
// Arrange
$requestException = new RequestException(
new Response(
new GuzzleHttp\Psr7\Response(
422,
[],
json_encode(['message' => 'Something went wrong.']),
),
)
);
// Act
$apiException = new ApiException(response: $requestException->response, previous: $requestException);
// Assert
expect($apiException->getCode())->toBe(422)
->and($apiException->response)->toBeInstanceOf(Response::class)
->and($apiException->response->json('message'))->toBe('Something went wrong.');
});
Using the Response in the Exception
Having the response from the request be part of the ApiException
is extremely helpful for a variety of purposes. For example, our application could have a UI to allow a user to add a product to the store. When submitting the request, we would likely validate as much as we could in our application, but maybe the third-party API has some additional validation that we can’t handle locally. We would likely want to return that information to our UI so the user knows what needs to be fixed.
If we make a call to create a product with our ProductResource
that we created in a previous post, and we receive an ApiClientException
, in our controller, we could catch that exception and return any errors received to the user in the frontend.
For simplicity, I created a very simple controller example. In a production application, you would likely have more validation for the request data using a FormRequest
class or $request->validate()
. For this example, we are assuming the third-party API returns validation error messages using a 422 and a response similar to how Laravel returns errors.
<?php
namespace App\Http\Controllers;
use App\ApiResources\ProductResource;
use App\Data\SaveProductData;
use App\Exceptions\ApiException;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function __construct(public readonly ProductResource $productResource)
{
}
public function create(Request $request)
{
try {
return $this->productResource->create(SaveProductData::from($request->all());
} catch (ApiException $exception) {
if ($exception->getCode() === 422) {
// 422 is typically the HTTP status code used for validation errors.
// Let's assume that the API returns an 'errors' property similar to Laravel.
$errors = $exception->response?->json('errors');
}
return response()->json([
'message' => $exception->getMessage(),
'errors' => $errors ?? null,
], $exception->getCode());
}
}
}
Additional Techniques
In your application, let’s say you have integrations with multiple third-party APIs. This means you likely have multiple client classes extending the base ApiClient
class. Instead of having a single ApiException
, it could be nice to have specific exceptions for each client. To do that, we can introduce a new $exceptionClass
property to the ApiClient
class.
abstract class ApiClient
{
protected string $exceptionClass = ApiException::class;
...
}
Now, when throwing the exception, we can throw an instance of whatever is set by the $exceptionClass
.
throw new $this->exceptionClass(
request: $request,
response: $exception?->response,
previous: $exception,
);
If we go back to the StoreApiClient
we created in a previous post, we can create a new exception for it and set it on the client. The exception can just simply extend the ApiException
class.
// StoreApiException
<?php
namespace App\Exceptions;
class StoreApiException extends ApiException
{
}
Then, we can update the client.
// StoreApiClient
<?php
namespace App\Support;
use App\Exceptions\StoreApiException;
class StoreApiClient extends ApiClient
{
protected string $exceptionClass = StoreApiException::class;
protected function baseUrl(): string
{
return config('services.store_api.url');
}
}
Let’s add a test to make sure the StoreApiClient
is throwing our new StoreApiException
.
it('throws a StoreApiException', function () {
// Arrange
Http::resetStubs();
Http::fakeSequence()->pushStatus(404);
$request = ApiRequest::get('products');
// Act
app(StoreApiClient::class)->send($request);
})->throws(StoreApiException::class, exceptionCode: 404);
What happens if someone decides to use an exception that doesn’t extend our ApiException
class? When our client tries to throw, it will fail if the $exceptionClass
is not expecting the same parameters. To handle that, let’s create an interface and use that to check the $exceptionClass
.
<?php
namespace App\Exceptions\Contracts;
use App\Support\ApiRequest;
use Illuminate\Http\Client\Response;
use Throwable;
interface ApiExceptionInterface
{
public function __construct(
?ApiRequest $request = null,
?Response $response = null,
Throwable $previous = null,
);
}
Now, update the ApiException
class to implement the interface.
class ApiException extends Exception implements ApiExceptionInterface
{
...
}
Finally, let’s update the ApiClient
to throw the $exceptionClass
only if it implements the ApiExceptionInterface
. Otherwise, let’s just throw the exception that was caught since we may not know how to instantiate a different type of exception.
abstract class ApiClient
{
...
public function send(ApiRequest $request): Response
{
try {
return $this->getBaseRequest()
->withHeaders($request->getHeaders())
->{$request->getMethod()->value}(
$request->getUri(),
$request->getMethod() === HttpMethod::GET
? $request->getQuery()
: $request->getBody()
);
} catch (Exception $exception) {
if (! is_subclass_of($this->exceptionClass, ApiExceptionInterface::class)) {
// If the exceptionClass does not implement the ApiExceptionInterface,
// let's just throw the caught exception since we don't know how to instantiate
// the exceptionClass.
throw $exception;
}
// Create our new exception and throw it.
throw new $this->exceptionClass(
request: $request,
response: $exception?->response,
previous: $exception,
);
}
}
...
}
We use the is_subclass_of
method which checks if the $exceptionClass
is a child of or implements the provided class. Since our $exceptionClass
for the StoreApiClient
extends the ApiException
class and does not overwrite the constructor, it implements the ApiExceptionInterface
.
Summary
In this post, we learned how to create a custom exception to make it easier to track and debug issues with third-party APIs. We created a custom ApiException
that was integrated with our ApiClient
. The exception included information about the request and response to make it easier to track down the cause of the issue.
As always, let me know if you have any questions or comments, and thanks for reading!
Top comments (0)