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

# Errors

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

## Catching one

**rescue_errors.rb**

```
message = {from: "billing@acme.com", to: "ada@example.com", subject: "Your September invoice", text: "Invoice attached."}

begin
  client.emails.send(message)
rescue OpenEmail::ApiError => error
  warn "#{error.code} #{error.param} #{error.message}" if error.validation?
  warn client.addresses.list_all.addresses.inspect if error.permission?
  warn "try again in #{error.retry_after_seconds} seconds" if error.rate_limited?

  warn "#{error.status} #{error.request_id}"
  raise
rescue OpenEmail::NetworkError => error
  warn "no answer in time" if error.timeout?
  raise
end
```

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 prints what `addresses.list_all` says this key may send as.

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

**rescue_by_class.rb**

```
message = {from: "billing@acme.com", to: "ada@example.com", subject: "Your September invoice", text: "Invoice attached."}

begin
  client.emails.send(message)
rescue OpenEmail::ValidationError => error
  warn "#{error.param}: #{error.message}"
rescue OpenEmail::AuthenticationError, OpenEmail::PermissionError => error
  warn "the key cannot do this: #{error.code}"
  raise
rescue OpenEmail::Error => error
  warn "#{error.class}: #{error.message}"
  raise
end
```

## The classes

| Class | When |
| --- | --- |
| `OpenEmail::Error` | The base of every error the gem defines, so `rescue OpenEmail::Error` catches all of them. It does not catch `ArgumentError`. |
| `OpenEmail::ApiError` | The API answered, and not with a success. Carries `status`, `type`, `code`, `param`, `doc_url`, `request_id`, `retry_after_seconds`, `fields` and `body`. Raised as itself when `type` is `api_error`, as a server fault is, and as the subclass for its `type` otherwise. |
| `OpenEmail::InvalidRequestError`, `AuthenticationError`, `PermissionError`, `NotFoundError`, `ConflictError`, `ValidationError` and `RateLimitError` | Subclasses of `ApiError`, one for each `type`: `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `conflict_error`, `validation_error` and `rate_limit_error`. |
| `OpenEmail::NetworkError` | No response arrived: DNS, TLS, a refused or dropped connection, or the timeout. Carries `original`, the exception underneath, which is also its `cause`, and `timeout?` is true when the timeout was the reason. |
| `OpenEmail::WebhookSignatureError` | `OpenEmail.verify_webhook_signature` refused a delivery. |
| `ArgumentError` | Raised before anything is sent: a missing or malformed key, an unusable `base_url:`, an empty id. It is the plain Ruby class, not an `OpenEmail::Error`, because it means the call itself is wrong. |

**What an ApiError carries**

- `message` (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 `code`.
- `status` (Integer): The HTTP status of the answer.
- `type` (String): One of the eight values in `OpenEmail::ERROR_TYPES`, a set that is frozen and will not grow. When the body names none, it is inferred from the status.
- `code` (String): The specific failure, such as `from_address_forbidden` or `invalid_email_address`. 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 nil): The field that was refused, as a dotted path such as `to.0`, when the failure names one.
- `doc_url` (String or nil): A page about this failure, when the API names one.
- `request_id` (String or nil): The id the server logged the request under, from the body or the `x-request-id` header.
- `retry_after_seconds` (Integer, Float or nil): The wait the server asked for in `Retry-After`, in seconds, whether it sent a number or a date. nil when it sent none.
- `fields` (Array<Hash> or nil): One Hash 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`. nil when the error lists none.
- `body` (Hash or nil): The whole error response, parsed, with Symbol keys. nil when it was empty or not JSON.

| Predicate | True when |
| --- | --- |
| `auth?` | `type` is `authentication_error`, a 401: no key, the wrong kind of credential, or a key we did not issue. |
| `permission?` | `permission_error`, a 403: a real key without the scope or the From address it needs. |
| `scope_missing?` | `code` is `insufficient_scope`, the 403 that names a missing scope. |
| `invalid_request?` | `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 `validation?` is the predicate that catches it. |
| `validation?` | `validation_error`, a 422: the schema refused it, and `param` names the field. |
| `not_found?` | `not_found_error`, a 404: no such resource. |
| `conflict?` | `conflict_error`, a 409: the resource is past the point where this could be done to it. |
| `rate_limited?` | `rate_limit_error`, a 429. `retry_after_seconds` holds the wait when the server named one. |
| `server_error?` | `status` is 500 or above. Quote `request_id` if you contact support. |
| `retryable?` | `status` is 408, 429, 500, 502, 503 or 504. |
| `step_up_required?` | `code` is `step_up_required`, the 403 an OAuth access token gets before a sensitive change until the person has verified a code. |

Most predicates read `type`, the frozen half of the envelope, and each subclass stands for one `type`. `code` 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 gem upgrade the price of reading a new failure mode.

> A body that is not the API’s error envelope still raises an `OpenEmail::ApiError`, with `type` inferred from the status and `code` set to `unrecognised_response`. A success whose body is not JSON raises one too.

> `retryable?` describes the status, not your call. A call that is safe to repeat has already been retried by the time it raises, 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 adapter raises becomes an `OpenEmail::NetworkError` once any retries the call allows are spent, with the original on `original` and `cause`. `NameError`, `TypeError` and `ArgumentError` are the exceptions: they mean a bug in the adapter, so they are raised unchanged and never retried.

## request_id

Every `OpenEmail::ApiError` 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 parsed body alone, so there is no request id to read on one.
