Circuit Breakers: Stop Hammering Dead APIs From Your Queue Workers

📖 3 minutes read

Your queue workers are burning through jobs at full speed, retrying a third-party API endpoint that’s been down for three hours. Every retry fails. Every failure generates a Sentry alert. You’re 55,000 errors deep, your queue is backed up, and the external service doesn’t care how many times you knock.

This is what happens when you don’t have a circuit breaker.

The Pattern

A circuit breaker sits between your application and an unreliable external service. It tracks failures and, after a threshold, stops sending requests entirely for a cooldown period. The metaphor comes from electrical engineering — when there’s too much current, the breaker trips to prevent damage.

Three states:

  • Closed — everything works normally, requests flow through
  • Open — too many failures, all requests short-circuit immediately (return error without calling the API)
  • Half-Open — after cooldown, let one request through to test if the service recovered

A Simple Implementation

class CircuitBreaker
{
    public function __construct(
        private string $service,
        private int $threshold = 5,
        private int $cooldownSeconds = 300,
    ) {}

    public function isAvailable(): bool
    {
        $failures = Cache::get("circuit:{$this->service}:failures", 0);
        $openedAt = Cache::get("circuit:{$this->service}:opened_at");

        if ($failures < $this->threshold) {
            return true; // Closed state
        }

        if ($openedAt && now()->diffInSeconds($openedAt) > $this->cooldownSeconds) {
            return true; // Half-open: try one request
        }

        return false; // Open: reject immediately
    }

    public function recordFailure(): void
    {
        $failures = Cache::increment("circuit:{$this->service}:failures");

        if ($failures >= $this->threshold) {
            Cache::put("circuit:{$this->service}:opened_at", now(), $this->cooldownSeconds * 2);
        }
    }

    public function recordSuccess(): void
    {
        Cache::forget("circuit:{$this->service}:failures");
        Cache::forget("circuit:{$this->service}:opened_at");
    }
}

Using It in a Queue Job

class FetchWeatherDataJob implements ShouldQueue
{
    public function handle(WeatherApiClient $client): void
    {
        $breaker = new CircuitBreaker('weather-api', threshold: 5, cooldownSeconds: 300);

        if (! $breaker->isAvailable()) {
            // Release back to queue for later
            $this->release(60);
            return;
        }

        try {
            $response = $client->getConditions($this->stationId);
            $breaker->recordSuccess();
            $this->storeWeatherData($response);
        } catch (ApiException $e) {
            $breaker->recordFailure();
            throw $e; // Let Laravel's retry handle it
        }
    }
}

Pair It With Exponential Backoff

Circuit breakers prevent hammering. Exponential backoff spaces out retries. Use both:

class FetchWeatherDataJob implements ShouldQueue
{
    public int $tries = 5;

    public function backoff(): array
    {
        return [30, 60, 120, 300, 600]; // 30s, 1m, 2m, 5m, 10m
    }
}

When You Need This

If your application integrates with external APIs that can go down — email verification services, geocoding providers, analytics feeds — you need circuit breakers. The symptoms that tell you it’s time:

  • Thousands of identical errors in your error tracker from one endpoint
  • Queue workers stuck retrying failed jobs instead of processing good ones
  • Your application slowing down because every request waits for a timeout
  • Rate limit responses (HTTP 429) from the external service

Without a circuit breaker, a flaky external service doesn’t just affect itself — it takes your entire queue infrastructure down with it. Five minutes of setup saves hours of firefighting.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *