Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Rate Limits, Quotas and Backoff: Calling Third-Party APIs Without Getting Blocked

About Post

Your integration worked perfectly for months. Then one morning the nightly sync job starts failing with 429 Too Many Requests, your code immediately retries, the retries get rejected too, and by the time anyone looks, you've spent the whole day's quota hammering an API that was politely asking you to slow down.

Every third-party API has limits. Payment gateways, SMS providers, maps, email services, AI models. The question isn't whether you'll hit them, it's whether your code reacts like a calm adult or a toddler pressing the lift button.

Here's how to handle rate limits properly: what the errors mean, how backoff with jitter works, and why a circuit breaker belongs in any integration you depend on.

Rate limits and quotas are not the same thing

They both end in "no", but they need different reactions:

  • Rate limit: how fast you may call. "100 requests per minute." Wait a little and you're fine again.
  • Quota: how much you may use in a longer period. "10,000 requests per day" or a monthly spend cap. Waiting a few seconds won't help; you're done until the window resets or someone upgrades the plan.

Many APIs return 429 for both. Read the response body and headers to tell them apart, because retrying a quota error every few seconds is just noise in your logs (and sometimes in theirs).

Read what the API is telling you

A well-behaved API tells you exactly when to come back. Look for:

  • Retry-After: a standard HTTP header, either a number of seconds or an HTTP date. If it's there, respect it. It beats any formula you invent.
  • Limit headers like X-RateLimit-Remaining and X-RateLimit-Reset. Names vary between providers, so check their docs. These let you slow down before you get rejected.

Not every error deserves a retry, either. My rough rule:

ResponseRetry?Why
Timeout / connection errorYes, with backoffUsually temporary (but see the note on idempotency below)
429 Too Many RequestsYes, after Retry-AfterYou're too fast, not wrong
502, 503, 504Yes, with backoffThe provider or a proxy is struggling
500Maybe, a few timesCould be temporary, could be your payload
400, 401, 403, 404, 422NoRetrying a wrong request just repeats the mistake

Exponential backoff, and why jitter matters

Retrying immediately is the worst option. If the provider is overloaded, you add to the load at exactly the wrong moment. Exponential backoff doubles the wait each time: 0.5s, 1s, 2s, 4s, up to a cap.

But backoff alone has a sneaky flaw. Picture a hundred workers that all failed at the same moment. With plain exponential backoff they all wait exactly 1 second, then all retry together, fail together, wait exactly 2 seconds... You've turned your workers into a synchronised swimming team that crashes into the API in waves. This is the "thundering herd".

Jitter fixes it by adding randomness. A popular version, "full jitter", picks a random delay between zero and the exponential value:

function backoffMs(int $attempt, int $baseMs = 500, int $capMs = 30_000): int
{
    $ceiling = min($capMs, $baseMs * (2 ** ($attempt - 1)));

    return random_int(0, $ceiling); // full jitter
}

Now the hundred workers spread their retries across the window instead of arriving as one wave.

Putting it together with Laravel's HTTP client

Laravel's HTTP client has retry() built in. The delay can be a closure, and a when callback decides which failures are worth retrying:

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;

$response = Http::timeout(10)->retry(
    times: 5,
    sleepMilliseconds: function (int $attempt, Throwable $e) {
        $after = $e instanceof RequestException
            ? $e->response->header('Retry-After') : '';

        return is_numeric($after) ? (int) $after * 1000 : backoffMs($attempt);
    },
    when: fn (Throwable $e) => $e instanceof ConnectionException
        || ($e instanceof RequestException
            && in_array($e->response->status(), [429, 502, 503, 504])),
)->get('https://api.example.com/v1/rates');

This is simplified (it ignores the HTTP-date form of Retry-After), but it covers the important parts: respect the server's hint, back off with jitter otherwise, and only retry errors that can succeed later.

One important caveat: retrying a POST can do the work twice. A timeout doesn't mean the request failed; it might have succeeded and the response got lost. For anything that creates or charges, send an idempotency key if the provider supports one.

Don't sleep inside a web request

Five retries with backoff can add up to a long wait. Inside a web request, that's a user staring at a spinner and a PHP worker doing nothing. Move third-party calls to a queued job where you can, and let the queue do the waiting.

Laravel jobs can release themselves back to the queue with a delay, and the RateLimited job middleware lets you stay under a provider's limit instead of discovering it the hard way:

// AppServiceProvider::boot()
RateLimiter::for('sms-provider', fn (object $job) => Limit::perMinute(60));

// In the job
public function middleware(): array
{
    return [new RateLimited('sms-provider')];
}

public function retryUntil(): DateTime
{
    return now()->addHours(2);
}

Use the limit from your provider's documentation, set slightly below it. Note that released jobs count as attempts, so use retryUntil() rather than a small $tries for rate-limited jobs.

The circuit breaker: knowing when to stop trying

Backoff is polite to a provider that's briefly busy. But if the provider is properly down for twenty minutes, every request still waits, retries and fails. Your queue fills up, your workers are all busy waiting on a dead API, and unrelated jobs get stuck behind them.

A circuit breaker works like the one in your house. After enough failures in a row, it "trips" and calls fail instantly for a cool-down period. After that, a few calls are let through to test whether the provider is back.

final class CircuitBreaker
{
    public function __construct(
        private string $name, private int $threshold = 5, private int $coolDown = 60,
    ) {}

    public function isOpen(): bool
    {
        return Cache::has("cb:{$this->name}:open");
    }

    public function recordFailure(): void
    {
        if (Cache::increment("cb:{$this->name}:fails") >= $this->threshold) {
            Cache::put("cb:{$this->name}:open", true, $this->coolDown);
            Cache::forget("cb:{$this->name}:fails");
        }
    }

    public function recordSuccess(): void
    {
        Cache::forget("cb:{$this->name}:fails");
    }
}

Before calling the provider, check isOpen(). If it's open, release the job for later or show a friendly "this service is temporarily unavailable" message instead of a timeout. It's a deliberately minimal sketch, using a shared cache like Redis so all workers see the same state; packages and gateways offer fuller versions.

The three layers: stay under the limit (throttling), recover from brief failures (backoff with jitter), and stop hammering a dead service (circuit breaker). Most integrations only have the middle one, and only half of it.

A checklist for your next integration

  • Know the provider's rate limits and quotas before you write code, not after the first 429.
  • Set a timeout on every outgoing request. The default can be far too long.
  • Retry only temporary errors, respect Retry-After, and add jitter.
  • Use idempotency keys for anything that creates, sends or charges.
  • Do slow third-party work in queued jobs, throttled below the provider's limit.
  • Add a circuit breaker for services your app can't afford to wait on.
  • Log and alert on 429s. A rising number is an early warning that you're outgrowing your plan.

Which API has given you the most creative rate-limit behaviour? I'm convinced every provider invents its own headers just to keep us humble.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close