# Errors and retries How failed requests are reported, and which requests are retried. ## Errors Every error the SDK throws at runtime extends `SynthientException`, which extends `\RuntimeException`. Invalid arguments throw `\InvalidArgumentException` instead, before any request is made. | Exception | Thrown when | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ApiException` | The API, or the storage behind an export download, answered with an error status. | | `ConnectionException` | The request could not be completed because of a network failure. | | `TimeoutException` | The connection, the response, or a read from a stream or download took longer than the client's timeout. It extends `ConnectionException`. | | `SynthientException` | The response was malformed, for example invalid JSON, a missing or mistyped field, or a body over 16 MiB. It is also thrown for a stream or download through the default HTTP client when `allow_url_fopen` is off, and it is the base class of the others. | ```php use Synthient\Exception\ApiException; try { $info = $client->lookupIp('8.8.8.8'); echo $info->ip, "\n"; } catch (ApiException $e) { if ($e->statusCode === 402) { echo "out of lookup credits\n"; } elseif ($e->statusCode === 429 && $e->retryAfter !== null) { echo "rate limited, retry after {$e->retryAfter} seconds\n"; } elseif ($e->statusCode === 429) { echo "monthly request budget spent\n"; } else { echo "request {$e->requestId} failed: ", $e->detail ?? $e->title, "\n"; } } ``` - **Cost:** A billed lookup that fails with a `ConnectionException` may or may not have been charged, so it is never retried automatically. - **Security:** Guzzle's own exception is never chained, because its request holds the API key. - **Response hook:** An exception thrown by `responseHook` reaches the caller unchanged. Common statuses are: - 401 for a missing or invalid key - 402 when the team is out of lookup credits - 403 when the key lacks a scope - 404 while a daily export is still rolling up - 422 for invalid input - 429 when rate limited See [Errors](https://docs.synthient.com/errors) for what each status means. ### ApiException - `statusCode` `int` — The HTTP status code of the response. `getCode()` returns it too. - `type` `?string` — The problem type URI. - `title` `?string` — A short summary of the problem type. - `detail` `?string` — The problem detail, or the body of a `text/plain` error. - `errors` `array>` — Validation errors by field, from a 422. Entries that are not lists of strings are dropped. - `requestId` `?string` — The `X-Request-Id` header, or the problem's `instance` when the header is absent. Quote it when contacting Synthient support. - `retryAfter` `?int` — Seconds from the `Retry-After` header, which may be a number of seconds or an HTTP date, or `null` if the header is absent or unreadable. Past dates and negative values become `0`. - `headers` `string[][]` — The response headers. * **Logging:** `getMessage()` holds only the status, such as `synthient: 402 Payment Required`, so it is safe to log. * **Untrusted fields:** `title`, `detail`, `errors`, and `headers` carry untrusted, possibly sensitive server data, so inspect them explicitly. * **Downloads:** An error from the storage host of an export download sets only `statusCode`. The constructor is public, so tests can create an `ApiException` with named arguments. ```php public function __construct( public readonly int $statusCode, public readonly ?string $type = null, public readonly ?string $title = null, public readonly ?string $detail = null, public readonly array $errors = [], public readonly ?string $requestId = null, public readonly ?int $retryAfter = null, public readonly array $headers = [], ?\Throwable $previous = null, ) ``` ## Retries and timeouts Which requests are retried: - **Account and feed requests** are retried on 500, 502, 503, and 504 responses, on transport failures other than TLS errors, and on 429 responses that carry `Retry-After`. They are `account()`, `listExports()`, `allExports()`, `exportMeta()`, `downloadUrl()`, the API request behind `download()`, and stream connections. - **Billed lookups** (`lookupIp()`, `lookupIps()`, and `lookupDomain()`) and `health()` are retried only on 429 responses that carry `Retry-After`, which the API returns before doing any work, so a retried lookup is never charged twice. They are also never sent on a reused connection, because libcurl silently resends a request whose reused connection dies before the response starts. - **A 429 without `Retry-After`** means the key's monthly request budget is spent or too many of its streams are open, so it is thrown without a retry. - **A failure while reading a stream or download** is never retried, and neither is a download's request to the export storage host. How retries wait: - Retries back off exponentially from one second, doubling up to 30 seconds, with 25% jitter. - The wait is never shorter than `Retry-After`. - A `Retry-After` longer than a minute is not waited for: the `ApiException` is thrown with its `retryAfter` instead. - Set the retry count with `maxRetries`. How timeouts work: - `connectTimeout` bounds each connection attempt. Guzzle's stream handler, which serves streams, downloads, and every request when the cURL extension is missing, ignores it and waits up to `timeout` for the connection instead. - `timeout` bounds each attempt's wait for the whole response, or for the next data on a stream or download. - The API sends a stream keepalive every 15 seconds. - A connection, a response, or a read that takes too long throws a `TimeoutException`. A connection or response timeout counts as a transport failure under the retry rules above, so it is thrown only once no retry is left. - Retries get a fresh timeout, so a call with retries can take longer than `timeout`. ```php use Synthient\Exception\TimeoutException; $client = new Synthient\Client(connectTimeout: 2.0, timeout: 5.0); try { $info = $client->lookupIp('8.8.8.8'); echo $info->ip, "\n"; } catch (TimeoutException $e) { echo $e->getMessage(), "\n"; } ```