---
title: "Errors"
description: "Every failure throws. One class for a refusal, one for no answer, and a request id on every API error."
url: "https://openemail.uk/docs/php/errors"
area: "PHP"
category: "Getting started"
---

# Errors

Every failure throws. One class for a refusal, one for no answer, and a request id on every API error.

## Catching one

**catch_errors.php**

```
use OpenEmail\Exception\ApiException;
use OpenEmail\Exception\NetworkException;

$message = [
    'from' => 'billing@acme.com',
    'to' => 'ada@example.com',
    '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;
}
```

A `permission_error` on a send is usually the KEY’s send scope, a domain or an address it was not given, rather than the workspace, which is why the sample logs what `addresses->listAll()` says this key may send as.

Each kind of refusal has a subclass of its own, so a `catch` can pick the ones it handles by class and let the rest go on up.

**catch_by_class.php**

```
use OpenEmail\Exception\AuthenticationException;
use OpenEmail\Exception\OpenEmailException;
use OpenEmail\Exception\PermissionException;
use OpenEmail\Exception\ValidationException;

$message = [
    'from' => 'billing@acme.com',
    'to' => 'ada@example.com',
    '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;
}
```

## The classes

Every class lives in `OpenEmail\Exception`.

| Class | When |
| --- | --- |
| `OpenEmailException` | The interface every exception the package throws implements, so `catch (OpenEmailException $error)` catches all of them, `InvalidArgumentException` included. |
| `ApiException` | The API answered, and not with a success. Carries `status`, `type`, `errorCode`, `param`, `docUrl`, `requestId`, `retryAfterSeconds`, `fields` and `body`. Thrown as itself when `type` is `api_error`, as a server fault is, and as the subclass for its `type` otherwise. It extends `RuntimeException`. |
| `InvalidRequestException`, `AuthenticationException`, `PermissionException`, `NotFoundException`, `ConflictException`, `ValidationException` and `RateLimitException` | Subclasses of `ApiException`, one for each `type`: `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `conflict_error`, `validation_error` and `rate_limit_error`. |
| `NetworkException` | No response arrived: DNS, TLS, a refused or dropped connection, or the timeout. `getPrevious()` holds the exception underneath, and `isTimeout()` is true when the timeout was the reason. It extends `RuntimeException`. |
| `WebhookSignatureException` | `OpenEmail::verifyWebhookSignature()` refused a delivery. It extends `UnexpectedValueException`. |
| `InvalidArgumentException` | Thrown before anything is sent: a missing or malformed key, an unusable `baseUrl:`, an empty id. It extends PHP’s own `InvalidArgumentException`, because it means the call itself is wrong. |

**What an ApiException carries**

- `getMessage()` (string): The API’s own sentence, written for a person and naming the offending value where there is one. Not a stable identifier, so switch on `errorCode`.
- `status` (int or null): The HTTP status of the answer, which `getCode()` returns too. null only when a success came back in a shape the client could not read.
- `type` (string): One of the eight values in `OpenEmail\Constants\ErrorTypes`, a set that is fixed and will not grow. When the body names none, it is inferred from the status.
- `errorCode` (string): The specific failure, such as `from_address_forbidden` or `invalid_email_address`. It is named `errorCode` because PHP keeps `code` for the number `getCode()` returns. Open and additive, so treat one you do not recognise as its `type`. It is `unrecognised_response` when the body was not the API’s error envelope.
- `param` (string or null): The field that was refused, as a dotted path such as `to.0`, when the failure names one.
- `docUrl` (string or null): A page about this failure, when the API names one.
- `requestId` (string or null): The id the server logged the request under, from the body or the `x-request-id` header.
- `retryAfterSeconds` (int, float or null): The wait the server asked for in `Retry-After`, in seconds, whether it sent a number or a date. null when it sent none.
- `fields` (array or null): One array per problem, each with `key` and `error`, such as `['key' => 'email', 'error' => 'email']` when `forms->subscribe` refused the answers with a 422 `invalid_form_submission`. null when the error lists none.
- `body` (mixed): The whole error response, decoded. null when it was empty or not JSON.

| Method | True when |
| --- | --- |
| `isAuth()` | `type` is `authentication_error`, a 401: no key, the wrong kind of credential, or a key we did not issue. |
| `isPermission()` | `permission_error`, a 403: a real key without the scope or the From address it needs. |
| `isScopeMissing()` | `errorCode` is `insufficient_scope`, the 403 that names a missing scope. |
| `isInvalidRequest()` | `invalid_request_error`, a 400: a request that could not be understood. A message over the size ceiling comes back as a 422 `message_too_large`, so `isValidation()` is the method that catches it. |
| `isValidation()` | `validation_error`, a 422: the schema refused it, and `param` names the field. |
| `isNotFound()` | `not_found_error`, a 404: no such resource. |
| `isConflict()` | `conflict_error`, a 409: the resource is past the point where this could be done to it. |
| `isRateLimited()` | `rate_limit_error`, a 429. `retryAfterSeconds` holds the wait when the server named one. |
| `isServerError()` | `status` is 500 or above. Quote `requestId` if you contact support. |
| `isRetryable()` | `status` is 408, 429, 500, 502, 503 or 504. |
| `isStepUpRequired()` | `errorCode` is `step_up_required`, the 403 an OAuth access token gets before a sensitive change until the person has verified a code. |

Most of these read `type`, the fixed half of the envelope, and each subclass stands for one `type`. `errorCode` stays a string, because the API guarantees it is open and additive, so treat one you do not recognise as its `type`. A closed list would make a package upgrade the price of reading a new failure mode.

> A body that is not the API’s error envelope still throws an `ApiException`, with `type` inferred from the status and `errorCode` set to `unrecognised_response`. A success whose body is not JSON throws one too.

> `isRetryable()` describes the status, not your call. A call that is safe to repeat has already been retried by the time it throws, and a 429 such as `send_quota_exceeded` or `ai_quota_exceeded` fails the same way until its allowance resets, so show it to a person rather than looping on it.

> An exception your HTTP client throws becomes a `NetworkException` once any retries the call allows are spent, with the original as `getPrevious()`. A `LogicException` and any `Error`, such as a `TypeError`, mean a bug in it, so they are thrown unchanged and never retried. `Psr18HttpClient` keeps only the message of an exception from the client it wraps, because that exception holds the request and its `Authorization` header.

## requestId

Every `ApiException` carries the request id the server sent, from the error body or the `x-request-id` header, and it is the only thing tying your failure to a line in the server’s log. A success returns the decoded body alone, so there is no request id to read on one.
