Errors and retries

View as markdown

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.

ExceptionThrown when
ApiExceptionThe API, or the storage behind an export download, answered with an error status.
ConnectionExceptionThe request could not be completed because of a network failure.
TimeoutExceptionThe connection, the response, or a read from a stream or download took longer than the client's timeout. It extends ConnectionException.
SynthientExceptionThe 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.
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 for what each status means.

ApiException

  • Name
    statusCode
    Type
    int
    Description

    The HTTP status code of the response. getCode() returns it too.

  • Name
    type
    Type
    ?string
    Description

    The problem type URI.

  • Name
    title
    Type
    ?string
    Description

    A short summary of the problem type.

  • Name
    detail
    Type
    ?string
    Description

    The problem detail, or the body of a text/plain error.

  • Name
    errors
    Type
    array<string, list<string>>
    Description

    Validation errors by field, from a 422. Entries that are not lists of strings are dropped.

  • Name
    requestId
    Type
    ?string
    Description

    The X-Request-Id header, or the problem's instance when the header is absent. Quote it when contacting Synthient support.

  • Name
    retryAfter
    Type
    ?int
    Description

    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.

  • Name
    headers
    Type
    string[][]
    Description

    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.

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.
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";
}