Handling Errors with Third-Party APIs

Handling Errors with Third-Party APIs

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!

Did you find this article valuable?

Support Sean Kegel by becoming a sponsor. Any amount is appreciated!