Handling Errors with Third-Party APIs
Tags: laravel, php, api, webdev
Published: 2024-01-30 00:19:07

## 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](https://seankegel.com/series/laravel-api-integration). 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](https://spatie.be/docs/laravel-data/v3/introduction) 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:

```bash
php artisan make:exception ApiException
```

Our new exception class can extend the `Exception` class and take three parameters.

```php
<?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
<?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.

```php
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.

```php
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.

```php
// 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`.

```php
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
<?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
<?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.

```php
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`.

```php
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.

```php
// StoreApiException
<?php

namespace App\Exceptions;

class StoreApiException extends ApiException
{
}
```

Then, we can update the client.

```php
// 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`.

```php
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
<?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.

```php
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.

```php
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!