Ошибки
Каждый сбой выбрасывает исключение. Один класс для отказа, один для отсутствия ответа и идентификатор запроса в каждой ошибке API.
Как его поймать
use OpenEmail\Exception\ApiException;use OpenEmail\Exception\NetworkException; $message = [ 'from' => '[email protected]', 'to' => '[email protected]', 'subject' => 'Your September invoice', 'text' => 'Invoice attached.',]; try { $client->emails->send($message);} catch (ApiException $error) { if ($error->isValidation()) { error_log($error->errorCode . ' ' . $error->param . ' ' . $error->getMessage()); } if ($error->isPermission()) { $book = $client->addresses->listAll(); error_log('this key may send as ' . implode(', ', array_column($book->addresses, 'address'))); } if ($error->isRateLimited()) { error_log('try again in ' . $error->retryAfterSeconds . ' seconds'); } error_log($error->status . ' ' . $error->requestId); throw $error;} catch (NetworkException $error) { if ($error->isTimeout()) { error_log('no answer in time'); } throw $error;}permission_error при отправке обычно связан с областью отправки самого КЛЮЧА, то есть с доменом или адресом, которые ему не выданы, а не с рабочим пространством. Поэтому пример записывает в журнал то, что addresses->listAll() сообщает о том, от чьего имени этот ключ может отправлять.
У каждого вида отказа есть собственный подкласс, поэтому catch может выбрать по классу те, которые обрабатывает, а остальные пропустить выше.
use OpenEmail\Exception\AuthenticationException;use OpenEmail\Exception\OpenEmailException;use OpenEmail\Exception\PermissionException;use OpenEmail\Exception\ValidationException; $message = [ 'from' => '[email protected]', 'to' => '[email protected]', 'subject' => 'Your September invoice', 'text' => 'Invoice attached.',]; try { $client->emails->send($message);} catch (ValidationException $error) { error_log($error->param . ': ' . $error->getMessage());} catch (AuthenticationException|PermissionException $error) { error_log('the key cannot do this: ' . $error->errorCode); throw $error;} catch (OpenEmailException $error) { error_log($error::class . ': ' . $error->getMessage()); throw $error;}Классы
Все классы находятся в OpenEmail\Exception.
| Класс | Когда |
|---|---|
| OpenEmailException | Интерфейс, который реализует каждое исключение, выбрасываемое пакетом, поэтому catch (OpenEmailException $error) перехватывает их все, включая InvalidArgumentException. |
| ApiException | API ответил, и ответ не был успешным. Несёт status, type, errorCode, param, docUrl, requestId, retryAfterSeconds, fields и body. Выбрасывается как есть, когда type равен api_error, как при сбое сервера, а в остальных случаях как подкласс для своего type. Наследует RuntimeException. |
| InvalidRequestException, AuthenticationException, PermissionException, NotFoundException, ConflictException, ValidationException и RateLimitException | Подклассы ApiException, по одному на каждый type: invalid_request_error, authentication_error, permission_error, not_found_error, conflict_error, validation_error и rate_limit_error. |
| NetworkException | Ответ не пришёл: DNS, TLS, отклонённое или оборванное соединение либо таймаут. getPrevious() хранит исходное исключение, а isTimeout() равно true, когда причиной был таймаут. Наследует RuntimeException. |
| WebhookSignatureException | OpenEmail::verifyWebhookSignature() отклонил доставку. Наследует UnexpectedValueException. |
| InvalidArgumentException | Выбрасывается до того, как что-либо отправлено: отсутствующий или неправильно сформированный ключ, непригодный baseUrl:, пустой идентификатор. Наследует собственный InvalidArgumentException из PHP, потому что означает, что неверен сам вызов. |
Что несёт ApiException
getMessage()string- Собственная фраза API, написанная для человека и называющая проблемное значение, если оно есть. Это не стабильный идентификатор, поэтому ветвитесь по `errorCode`.
statusint or null- HTTP-статус ответа, который также возвращает `getCode()`. Равен null, только когда успешный ответ пришёл в виде, который клиент не смог прочитать.
typestring- Одно из восьми значений в `OpenEmail\Constants\ErrorTypes`, фиксированном наборе, который не будет расти. Если тело его не называет, оно выводится из статуса.
errorCodestring- Конкретный сбой, например `from_address_forbidden` или `invalid_email_address`. Он называется `errorCode`, потому что в PHP `code` занят числом, которое возвращает `getCode()`. Набор открытый и пополняемый, поэтому незнакомый код обрабатывайте по его `type`. Равен `unrecognised_response`, когда тело не было конвертом ошибки API.
paramstring or null- Отклонённое поле в виде пути через точку, например `to.0`, если сбой его называет.
docUrlstring or null- Страница об этом сбое, если API её называет.
requestIdstring or null- Идентификатор, под которым сервер записал запрос в журнал, из тела или заголовка `x-request-id`.
retryAfterSecondsint, float or null- Время ожидания в секундах, которое сервер запросил в `Retry-After`, прислал ли он число или дату. null, если он ничего не прислал.
fieldsarray or null- По одному массиву на каждую проблему, в каждом `key` и `error`, например `['key' => 'email', 'error' => 'email']`, когда `forms->subscribe` отклонил ответы с 422 `invalid_form_submission`. null, если ошибка не перечисляет проблем.
bodymixed- Весь ответ с ошибкой, декодированный. null, если он был пуст или не был JSON.
| Метод | True, когда |
|---|---|
| isAuth() | type равен authentication_error, 401: ключа нет, учётные данные не того типа или ключ, который выдавали не мы. |
| isPermission() | permission_error, 403: настоящий ключ без нужной области или без нужного адреса From. |
| isScopeMissing() | errorCode равен insufficient_scope: это 403, который называет недостающую область. |
| isInvalidRequest() | invalid_request_error, 400: запрос, который не удалось разобрать. Письмо сверх предельного размера возвращается как 422 message_too_large, поэтому ловит его метод isValidation(). |
| isValidation() | validation_error, 422: схема отклонила запрос, а param называет поле. |
| isNotFound() | not_found_error, 404: такого ресурса нет. |
| isConflict() | conflict_error, 409: ресурс уже прошёл ту точку, в которой с ним можно было это сделать. |
| isRateLimited() | rate_limit_error, 429. retryAfterSeconds содержит время ожидания, если сервер его назвал. |
| isServerError() | status равен 500 или выше. При обращении в поддержку укажите requestId. |
| isRetryable() | status равен 408, 429, 500, 502, 503 или 504. |
| isStepUpRequired() | errorCode равен step_up_required: это 403, который токен доступа OAuth получает перед чувствительным изменением, пока человек не подтвердит код. |
Большинство из них читают type, фиксированную половину конверта, и каждый подкласс соответствует одному type. errorCode остаётся строкой, потому что API гарантирует, что этот набор открытый и пополняемый, поэтому незнакомый код обрабатывайте по его type. С закрытым списком ценой чтения нового вида сбоя стало бы обновление пакета.
Тело, которое не является конвертом ошибки API, всё равно выбрасывает ApiException с type, выведенным из статуса, и errorCode, равным unrecognised_response. Успешный ответ, тело которого не является JSON, тоже его выбрасывает.
isRetryable() описывает статус, а не ваш вызов. Вызов, который безопасно повторять, к моменту исключения уже был повторён, а 429, например send_quota_exceeded или ai_quota_exceeded, будет завершаться так же, пока его лимит не обнулится, поэтому покажите его человеку, а не повторяйте в цикле.
Исключение, которое выбрасывает ваш HTTP-клиент, становится NetworkException, когда исчерпаны все повторы, допустимые для вызова, а исходное исключение доступно как getPrevious(). LogicException и любой Error, например TypeError, означают ошибку в самом клиенте, поэтому выбрасываются без изменений и никогда не повторяются. Psr18HttpClient сохраняет от исключения обёрнутого клиента только сообщение, потому что это исключение содержит запрос и его заголовок Authorization.
requestId
Каждый ApiException несёт идентификатор запроса, который прислал сервер, из тела ошибки или заголовка x-request-id, и это единственное, что связывает ваш сбой со строкой в журнале сервера. Успешный ответ возвращает только декодированное тело, поэтому идентификатора запроса у него нет.