Обработка ошибок при работе со сторонними API

Источник: «Handling Errors with Third-Party APIs»
Узнайте, как создавать, выбрасывать и обрабатывать исключения при выполнении запросов к сторонним API.

Это четвёртая часть серии статей об интеграции сторонних API в Laravel. Если вы не читали предыдущие статьи, рекомендую ознакомиться с ними, чтобы получить необходимый контекст для лучшего понимания этой статьи.

Серия статей "Интеграция сторонних API в Laravel":

В предыдущих статьях серии мы узнали как создать следующее:

В этих статьях мы также узнали, как тестировать различные интеграции с помощью Http фасада и фейковых ответов. Однако мы тестировали только лучшие сценарии и не рассматривали обработку ошибок. Именно это я и хочу продемонстрировать в этой статье.

Создание исключения

Для начала рекомендую создать исключение. Назовём его ApiException. Создайте его вручную или воспользуйтесь командой Artisan:

php artisan make:exception ApiException

Наш новый класс исключений расширяет класс Exception и принимает три параметра.

<?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,
) {
// Как правило, мы просто передаём сообщение предыдущего исключения,
// но указываем значение по умолчанию, если по какой-то причине
// выбросили это исключение без предыдущего.
$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(),
];
}
}

Конструктор принимает свойства $request, $response и $previous.

Свойство $request — экземпляр класса ApiRequest, созданный нами в предыдущей статье для хранения такой информации, как URL, метод, тело данных, строки запроса и т. д.

$response — экземпляр класса Laravel Illuminate\Http\Client\Response, возвращаемый при использовании Http фасада для выполнения запроса. Добавив ответ к исключению, можно собрать гораздо больше информации, если это необходимо при обработке исключения, например, объект ошибки из стороннего API.

Наконец, используя предыдущее исключение, если оно существует, мы выбрасываем ApiException, используя данные из предыдущего исключения или просто значения по умолчанию.

Я добавил метод context, предоставляющий больше информации, получаемой из свойства $request. В зависимости от приложения, данные и параметры запроса могут содержать конфиденциальную информацию, поэтому убедитесь, что понимаете, что именно добавляется. Для некоторых приложений сам URL может быть конфиденциальным, поэтому настройте его по необходимости или добавьте в context параметр и передавайте любые данные, которые вам необходимы.

Выбрасывание исключения ApiException

Теперь, когда есть новый класс исключений, рассмотрим, как на самом деле выбрасывать его при возникновении ошибки. Можно обновить класс ApiClient из предыдущих статей, чтобы теперь перехватывать исключения, использовать ApiException в качестве обёртки и включать информацию о запросе.

<?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
{
/**
* Отправляем ApiRequest в API и возвращаем ответ.
* @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) {
// Создаём новое исключение и выбрасываем его.
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;
}

Чтобы выбросить исключение, достаточно обернуть возврат в методе send с помощью try...catch, а затем выбросить новое исключение. Когда устанавливаем $response в исключении, мы пытаемся взять его из свойства response пойманного исключения. Если запрос был отправлен, но не был выполнен, Http фасад выбрасывает Illuminate\Http\Client\RequestException, имеющий свойство response, являющееся экземпляром Illuminate\Http\Client\Response. Если будет поймано другое исключение, мы просто установим ответ в null.

Тестирование

Тестирование клиента

Чтобы протестировать новое исключение, создадим файл ApiClientTest.php и добавим в него следующий тест.

it('throws an api exception', function () {
// Организация
Http::fakeSequence()->pushStatus(500);
$request = ApiRequest::get('foo');

// Действие
$this->client->send($request);
})->throws(ApiException::class, exceptionCode: 500);

Этот тест использует вызов Http::fakeSequence() и отправляет ответ с кодом состояния 500. Затем мы ожидаем, что клиент выбросит ApiException с кодом исключения 500.

Вы можете заметить, что этот тест провалился. Это происходит потому, что мы использовали Http::fake() в методе beforeEach теста.

beforeEach(function () {
Http::fake();

$this->client = new class extends ApiClient {
protected function baseUrl(): string
{
return 'https://example.com';
}
};
});

Вызывая Http::fake(), мы, по сути, указываем ему, что нужно имитировать любой запрос, сделанный с помощью фасада. Он делает это, проталкивая запись во внутреннюю коллекцию. Даже если добавить дополнительные элементы в Http::fake() или Http::fakeSequence(), фейковый ответ всё равно будет взят из первого элемента в коллекции, поскольку в нем не указан конкретный URL. Это работает подобно маршрутизатору, находящему первый подходящий маршрут, который может быть использован для имитации ответа.

Для решения этой проблемы можно либо перенести Http::fake() в сами тесты. Однако мне нравится другой подход, заключающийся в добавлении макроса в Http фасад, позволяющего сбросить внутреннюю коллекцию, называемую stubCallbacks. Для этого откройте свой AppServiceProvider и добавьте макрос в метод boot.

// AppServiceProvider

public function boot(): void
{
Http::macro('resetStubs', fn () => $this->stubCallbacks = collect());
}

Теперь, вместо того чтобы добавлять Http::fake() во все предыдущие тесты, можно обновить новый тест, чтобы он вызывал Http::resetStubs.

it('throws an api exception', function () {
// Организация
Http::resetStubs();
Http::fakeSequence()->pushStatus(500);
$request = ApiRequest::get('foo');

// Действие
$this->client->send($request);
})->throws(ApiException::class, exceptionCode: 500);

Тестирование исключения

Теперь, когда мы убедились, что клиент выбрасывает API исключение, давайте добавим тесты для самого исключения.

<?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 () {
// Действие
$apiException = new ApiException();

// Утверждение
expect($apiException)
->getMessage()->toBe('An error occurred making an API request.')
->getCode()->toBe(0);
});

it('sets context based on request', function () {
// Организация
$request = ApiRequest::get(fake()->url);

// Действие
$apiException = new ApiException($request);

// Утверждение
expect($apiException)->context()->toBe([
'uri' => $request->getUri(),
'method' => $request->getMethod(),
]);
});

it('gets response from RequestException', function () {
// Организация
$requestException = new RequestException(
new Response(
new GuzzleHttp\Psr7\Response(
422,
[],
json_encode(['message' => 'Something went wrong.']),
),
)
);

// Действие
$apiException = new ApiException(response: $requestException->response, previous: $requestException);

// Утверждение
expect($apiException->getCode())->toBe(422)
->and($apiException->response)->toBeInstanceOf(Response::class)
->and($apiException->response->json('message'))->toBe('Something went wrong.');
});

Использование ответа в исключении

То, что ответ на запрос является частью ApiException, очень удобно для различных целей. Например, наше приложение может иметь пользовательский интерфейс, позволяющий пользователю добавить товар в магазин. При отправке запроса мы, скорее всего, проверим всё, что сможем, в приложении, но, возможно, сторонний API имеет дополнительную проверку, которую мы не можем обработать локально. Скорее всего, мы захотим вернуть эту информацию в наш пользовательский интерфейс, чтобы пользователь знал, что нужно исправить.

Если мы сделаем вызов для создания товара с помощью ProductResource, созданного нами в предыдущей статье, мы получим ApiClientException в нашем контроллере. Мы можем перехватить это исключение и вернуть все полученные ошибки пользователю во фронтенде.

Для простоты я создал очень простой пример контроллера. В рабочем приложении вы, скорее всего, проведёте дополнительную проверку данных запроса с помощью класса FormRequest или $request->validate(). В данном примере мы предполагаем, что сторонний API возвращает сообщения об ошибках валидации с помощью 422 и ответа, аналогичного тому, как Laravel возвращает ошибки.

<?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 - это код состояния HTTP, обычно используемый для ошибок валидации.
// Предположим, что API возвращает свойство 'errors', как в Laravel.
$errors = $exception->response?->json('errors');
}

return response()->json([
'message' => $exception->getMessage(),
'errors' => $errors ?? null,
], $exception->getCode());
}
}
}

Дополнительные техники

Допустим, в приложении есть интеграция с несколькими сторонними API. Это означает, что, скорее всего, есть несколько клиентских классов, расширяющих базовый класс ApiClient. Вместо того чтобы иметь единое исключение ApiException, было бы неплохо иметь отдельные исключения для каждого клиента. Для этого мы можем ввести новое свойство $exceptionClass в класс ApiClient.

abstract class ApiClient
{
protected string $exceptionClass = ApiException::class;

...
}

Теперь, выбрасывая исключение, можно выбрасывать экземпляр того, что задано в $exceptionClass.

throw new $this->exceptionClass(
request: $request,
response: $exception?->response,
previous: $exception,
);

Если вернуться к StoreApiClient, который был создан в предыдущей статье, то можно создать для него новое исключение и установить его на клиенте. Исключение может просто расширять класс ApiException.

// StoreApiException
<?php

namespace App\Exceptions;

class StoreApiException extends ApiException
{
}

Затем можно обновить клиент.

// 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');
}
}

Добавим тест, чтобы убедиться, что StoreApiClient выбрасывает новый StoreApiException.

it('throws a StoreApiException', function () {
// Организация
Http::resetStubs();
Http::fakeSequence()->pushStatus(404);
$request = ApiRequest::get('products');

// Действие
app(StoreApiClient::class)->send($request);
})->throws(StoreApiException::class, exceptionCode: 404);

Что произойдёт, если кто-то решит использовать исключение, не расширяющее класс ApiException? Когда клиент попытается выбросить исключение, он потерпит неудачу, если $exceptionClass не ожидает тех же параметров. Чтобы это исправить, давайте создадим интерфейс и будем использовать его для проверки $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,
);
}

Теперь обновляем класс ApiException, чтобы он реализовал этот интерфейс.

class ApiException extends Exception implements ApiExceptionInterface
{
...
}

Наконец, изменим ApiClient так, чтобы он выбрасывал $exceptionClass только в том случае, если он реализует ApiExceptionInterface. В противном случае будем просто выбрасывать пойманное исключение, поскольку не знаем, как создать исключение другого типа.

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)) {
// Если exceptionClass не реализует ApiExceptionInterface, просто
// выбрасываем пойманное исключение, поскольку мы не знаем, как
// инстанцировать exceptionClass.
throw $exception;
}

// Создаём новое исключение и выбрасываем его.
throw new $this->exceptionClass(
request: $request,
response: $exception?->response,
previous: $exception,
);
}
}

...
}

Мы используем метод is_subclass_of, проверяющий, является ли $exceptionClass дочерним или реализует предоставленный класс. Поскольку $exceptionClass для StoreApiClient расширяет класс ApiException и не перезаписывает конструктор, он реализует ApiExceptionInterface.

Итоги

В этой статье мы узнали, как создать собственное исключение, чтобы упростить отслеживание и отладку проблем со сторонними API. Мы создали исключение ApiException, интегрированное с ApiClient. Исключение содержит информацию о запросе и ответе, чтобы было проще отследить причину проблемы.

Дополнительные материалы

Предыдущая Статья

Создание API ресурсов в Laravel

Следующая Статья

Современные команды и возможности Git