Skip to content
LaravelBlog

laravel/ai: Retry once after a short Retry-After before failing over

LaravelBlogBot
LaravelBlogBot

Contributed by augusto-dmh

Part of the ai v1.2.1 release

When a provider rate limits a request, it usually tells you exactly how long to wait — that is what the Retry-After header is for. Until now, laravel/ai ignored it: a 429 or an overloaded status triggered an immediate failover to the next provider, or threw a RateLimitedException when there was no other provider to try. PR #1112 teaches withErrorHandling() to honor short Retry-After values: it waits the requested number of seconds, retries the same provider once, and only then falls back to the existing failover behavior.

What changed

The HandlesFailoverErrors trait wraps every gateway HTTP call in withErrorHandling(). Instead of returning the callback's result or throwing on the first failure, it now runs it through Laravel's retry() helper with a maximum of two attempts:

return retry(
    2,
    $callback,
    fn (int $attempt, Throwable $exception): int => $this->retryAfterSeconds($exception) * 1000,
    fn (Throwable $exception): bool => $this->retryAfterSeconds($exception) !== null,
);

The new retryAfterSeconds() method inspects the exception and returns a delay only when all of the following hold: the exception is a RequestException carrying a response, the status is 429 or one of the gateway's overloadedStatusCodes() (such as 503 or 529), and the Retry-After header is a plain number of seconds that is at most 10. Anything else skips the retry, and the exception flows into the existing mapping — RateLimitedException for rate limits, ProviderOverloadedException for overload statuses — so failover proceeds exactly as it does on 1.x.

When the retry happens — and when it doesn't

The gate is deliberately narrow, so providers that ask for a quick pause get one, and everything else behaves as before:

  • Retried once: 429 with Retry-After: 1, or an overloaded status with Retry-After: 5.
  • Not retried: a missing header, a delay longer than 10 seconds, an HTTP-date value like Wed, 21 Oct 2026 07:28:00 GMT, fractional values like 1.5, or any non-rate-limit status such as 400.

There is exactly one retry. If the second attempt is rate limited too, the exception is thrown and handled (or failed over) just as it was on 1.x.

Why it matters

This fixes two everyday annoyances. With a single provider, a brief burst of rate limiting used to fail the whole prompt outright. With multiple providers, every short limit on the primary silently moved the run to the backup — a different model with different pricing, latency, and behavior:

$response = (new AssistantAgent)->prompt('Hi', provider: ['anthropic', 'openai']);

On 1.x, if Anthropic answers 429 with Retry-After: 1, this immediately runs on OpenAI and dispatches an AgentFailedOver event. With this change, the SDK waits one second, retries Anthropic, and returns Anthropic's answer — no failover event, and you stay on the model you configured.

All call types are covered

Because the retry lives in withErrorHandling(), every gateway call gets it for free: chat and agent prompts, streaming, images, audio, and embeddings. For streams, the status error arrives before any event is yielded, so the retry is fully transparent — the stream simply starts a moment later. There is no new configuration option; the behavior is always on. The wait goes through Laravel's Sleep, so Sleep::fake() keeps test suites fast.

Upgrading from v1.2.0

There is nothing to migrate: no API changes, no config changes, and the exception types are unchanged. The difference is behavioral — a call that used to fail over instantly can now block for up to ten seconds per provider before moving on, so latency-budget applications should be aware of the new wait. If your own tests exercise failover paths, add Sleep::fake() the way the new feature tests do:

Sleep::fake();

Http::fakeSequence()
    ->push(status: 429, headers: ['Retry-After' => '3'])
    ->pushResponse(fakeGroqResponse('Hello after waiting.'));

$response = (new AssistantAgent)->prompt('Hi', provider: ['primary', 'backup']);

expect($response->meta->provider)->toBe('primary');

Sleep::assertSequence([Sleep::for(3)->seconds()]);
Event::assertNotDispatched(AgentFailedOver::class);

Coverage is split between HandlesFailoverErrorsTest, which covers the retry on 429 and on an overloaded status, a second 429 still failing over, and no retry for a missing header, a long delay, an HTTP-date, or a 400, and AgentRetryAfterTest, which verifies the same flow end-to-end through an agent with two providers. The retry tests fail on 1.x, so a green suite after upgrading confirms the new behavior is in place.

Takeaways

  • Responses with a 429 or overloaded status and a numeric Retry-After of 10 seconds or less are retried once against the same provider after waiting that delay.
  • Missing, HTTP-date, fractional, or longer headers skip the retry and fail over immediately, exactly as in 1.x.
  • The retry applies everywhere through withErrorHandling(), including streaming, images, audio, and embeddings.
  • No new config and no breaking changes; the only visible difference is a possible wait of up to 10 seconds before failover.
  • Use Sleep::fake() in your own tests to keep the new wait from slowing your suite.

Sources

More from this release

Related Articles