Ask
29

A failing job retried itself into taking down the service it depended on: how should retries actually be designed?

An upstream API started returning errors. My worker retried, as designed. Within a few minutes the queue had thousands of jobs all retrying, the upstream got substantially more traffic than normal, and what had been a partial outage became a complete one.

When the upstream recovered it immediately fell over again, because the entire backlog hit it at once.

My retry logic is a fixed three attempts, five seconds apart. That was clearly wrong and I do not know what right looks like.

How do people design this so that a failure stays contained instead of amplifying?

9 answers Share
Report

Answering anonymously, a moderator will review it first.

  • @circuit_breaker_cem · 3w ago · 4 replies

    The piece that would have prevented the amplification specifically: stop calling something that is clearly down.

    A circuit breaker is simple in concept. Count recent failures for a dependency; if they cross a threshold, stop making calls entirely for a period and fail immediately. After the period, let one request through - if it succeeds, resume; if not, wait again.

    What that does for your incident:

    Your service stops adding load to something already struggling. This is the difference between a partial outage and the total one you got.

    Jobs fail fast instead of occupying workers for the full timeout. A large part of these incidents is every worker blocked waiting on a dead dependency, so nothing else moves either.

    Recovery is controlled. One probe request rather than the whole backlog.

    Alongside it, two things worth having:

    A concurrency limit per dependency. Never more than N in flight to any one external service, regardless of how many jobs are ready. This alone caps the damage.

    Rate limiting on the way out, so a drained backlog leaves at a survivable pace rather than all at once.

    Most queue libraries have some of this built in, and it is usually off by default.

    26
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @thundering_herd · 3w ago · 3 replies

      The recovery half of your incident deserves its own name, because the fix for it is separate from everything above. The backlog hitting at once when the upstream came back is a thundering herd, and a circuit breaker does not prevent it: the breaker closes, every worker resumes at the same instant, and you knock it over a second time.

      What prevents it is jitter on the backoff and a concurrency limit on the worker pool. Jitter so the retries spread out instead of arriving in a wave, and a hard cap so recovery is a trickle rather than the entire queue.

      25
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
      • @circuit_breaker_cem · 3w ago

        Correct, and the half-open state is the part people implement as a boolean and then get exactly this. One request through, not all of them.

        17
        Share
        Reply

        Answering anonymously, a moderator will review it first.

        Report
      • @oncall_last_night · 3w ago

        Learned this at 3am. The second outage was worse than the first because everything was warm and confident.

        11
        Share
        Reply

        Answering anonymously, a moderator will review it first.

        Report
  • @which_errors_retry · 3w ago · 2 replies

    Start before the timing, with the question your current logic skips: which failures are worth retrying at all?

    Sort every failure into three:

    Retryable. Timeouts, connection failures, rate limits, five hundreds. The request might succeed later.

    Permanent. Bad input, not found, unauthorised, a validation failure. Retrying is guaranteed waste, and it is a large share of most retry volume.

    Ambiguous. A timeout where the write might have succeeded. These need idempotency rather than retries, a key the receiver uses to recognise the same request.

    Retrying everything three times is a third of your problem: you tripled the load including on the calls that could never succeed.

    Then the timing, and there are two parts:

    Exponential backoff. Each attempt waits longer than the last - a second, then two, four, eight. This is what stops a fast failure loop from becoming a flood.

    Jitter. Randomise each delay. Without it, everything that failed together retries together, forever, in waves. That is exactly your recovery collapse - the whole backlog was synchronised by the outage and hit at the same instant.

    Backoff without jitter is the mistake people make after learning about backoff.

    30
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @which_errors_retry · 3w ago

      Both of these are better answers than mine and I would reorder the whole thread if I could.

      10
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
  • @oncall_last_night · 3w ago

    Fixed three attempts five seconds apart is in every tutorial and it is wrong in every one of them.

    7
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
  • @backoff_and_jitter · 3w ago · 2 replies

    Two operational details that matter as much as the algorithm.

    Cap the total attempts and send failures somewhere. After the final attempt, the job goes to a dead letter queue rather than being retried forever or dropped. Then a person can look at what failed and requeue deliberately when the dependency is genuinely healthy. Draining a dead letter queue on purpose is completely different from a backlog releasing itself.

    Make retries visible. A metric for retry count per dependency, and an alert when it rises sharply. In your incident the retry rate would have gone up minutes before anything else looked wrong, and it is the earliest signal you can get. Most people only alert on failures, which fires later.

    On the specific shape I would use for your case: exponential backoff with full jitter, a small number of attempts for genuinely transient failures, no retries at all for permanent ones, a concurrency cap on the upstream, and a breaker.

    And one thing to check while you are in there: make sure retries are not nested. An HTTP client that retries three times, inside a job that retries three times, is nine calls per job and nobody planned it. Layered retries are how a modest policy becomes an accidental flood, and they are very easy to acquire without noticing.

    1
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @queue_depth_alarm · 3w ago

      Operationally, the alert that would have caught this early is queue depth and its rate of change, not the error rate. Errors were probably normal-ish for a while: it was the depth climbing that was the incident.

      Retry count as a metric is good, depth is better, because depth is the thing that turns a dependency problem into an outage.

      18
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report