Skip to content

Retries

GET requests default to 3 attempts (1 original + up to 2 retries) on transient errors. Non-GET requests default to 1 attempt (no retries). Override per-request with maxAttempts.

Basic usage

php
$tickets = $integration
    ->at('/api/v2/tickets.json')
    ->as(TicketListResponse::class)
    ->withAttempts(3)
    ->get(fn () => Http::get($url));

Each retry is persisted as its own IntegrationRequest row with retry_of pointing to the first attempt. Every attempt counts toward rate limiting and is visible in logs.

Retries and the circuit breaker are separate axes

"Should I try again?" and "is the upstream down?" are different questions. A request can be retryable without being an outage — a 429 is retried (honouring Retry-After) but does not trip the circuit breaker or degrade health, because the upstream is healthy and just pacing you. Only genuine upstream faults (5xx, connection errors, timeouts) count against the breaker.

Backoff strategy

StatusBackoff
Retry-AfterRespects the header value, capped at configured max (default 10 min)
429Fixed 30-second delay (when no Retry-After header)
5xxExponential (attempt x 2s)
Connection errorLinear (attempt x 1s)
4xx (except 429)Not retried, thrown immediately

RetryableException

When your code knows an error is retryable, throw RetryableException:

php
use Integrations\Exceptions\RetryableException;

throw new RetryableException(
    'GitHub API secondary rate limit',
    retryAfterSeconds: 60,
);

The exception accepts two optional hints:

ParameterDescription
retryAfterSecondsDelay before the next attempt (capped by retry_after_max_seconds config)
maxAttemptsMaximum attempts for this specific error (caps the executor's maxAttempts)

RetryableException has the highest priority in the retry decision chain: it overrides both CustomizesRetry and the default status-code logic. You can also wrap third-party exceptions:

php
catch (ZendeskRateLimitException $e) {
    throw new RetryableException(
        'Zendesk rate limited',
        retryAfterSeconds: $e->getRetryAfter(),
        previous: $e,
    );
}

Domain-specific subclasses work too: extend RetryableException.

SDK exception support

The retry handler walks the exception chain (getPrevious()) to detect retryable status codes and connection errors wrapped by third-party SDKs. If your SDK wraps a Guzzle, Laravel HTTP, or Symfony HTTP exception as a previous exception, retries work automatically with no adapter code needed.

For SDKs that throw completely custom exceptions (not wrapping Guzzle), you have two options:

  • Throw a RetryableException from the call site when you know an error is transient. Best for adapters and code you control.
  • Implement CustomizesRetry on the provider to inspect exceptions after the fact. Best for third-party SDK exceptions you can't modify.
  • Implement ClassifiesFailures on the provider. Classification doubles as a retry signal: a class of Upstream or Throttle is retried automatically (after RetryableException and CustomizesRetry have had their say), so a provider that classifies its SDK's exceptions gets correct retry behaviour and correct breaker behaviour from one method.

Standalone retry handler

The RetryHandler can be used independently of Integration::request():

php
use Integrations\RetryHandler;

$result = RetryHandler::execute(
    callback: fn () => Http::get($url)->throw(),
    maxAttempts: 3,
    retryableStatusCodes: [429, 500, 502, 503, 504],
    onRetry: function (int $attempt, Throwable $e) {
        Log::warning("Retry attempt {$attempt}", ['error' => $e->getMessage()]);
    },
);

Configuration

The Retry-After header cap is configurable in config/integrations.php:

php
'retry' => [
    'retry_after_max_seconds' => 600, // cap at 10 minutes
],