Skip to content

Build1 publisher3 min readPublished

A retry cap is not a retry budget, and each language breaks it in a different place

A dev.to implementation note ports one token-bucket retry budget into Python, Go, and JavaScript. The Python trap is a missing lock; the Go trap is the select.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • The post argues the retry-budget argument is language-independent: a per-call cap bounds a call but never a run or a fleet of workers.
  • The post states that your retry multiplier is set by how you recover, not by how often you fail, and that theory does not tell you where in a Python decorator, a Go for loop, or a JavaScript promise chain the budget actually lives; each language makes a different part of it easy to get wrong.
  • A retry budget is defined as a bucket shared across everything that might retry, refilled slowly, that caps retries as a fraction of throughput rather than as an absolute count per call.
  • Property one: the budget is shared state. If every call site owns its own counter, you do not have a budget, you have N caps that multiply.
  • Property two: a denied retry is a real outcome. When the bucket is empty the call fails now, deliberately, instead of waiting for a slot; retrying is a privilege the system can revoke, not a right the call holds.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

An implementation note published on dev.to, "Retry budgets by language: Python, Go, and JavaScript," takes a claim that most teams nod along to and then ignore in code: a per-call cap bounds a call, but never a run or a fleet of workers [1]. The author's framing is that your retry multiplier is set by how you recover, not by how often you fail, and that the theory does not tell you where in a Python decorator, a Go for loop, or a JavaScript promise chain the budget actually lives [1][2].

The definition is narrow enough to be useful. A retry budget is a bucket shared across everything that might retry, refilled slowly, that caps retries as a fraction of throughput rather than as an absolute count per call [3]. Two properties have to survive translation into any runtime: the budget is shared state, because if every call site owns its own counter you do not have a budget, you have N caps that multiply [4]; and a denied retry is a real outcome, meaning that when the bucket is empty the call fails now, deliberately, rather than waiting for a slot [5]. The shared shape is a token bucket with capacity C and refill R tokens per second, a small deposit on each success and a withdrawal of one token per retry, or a denial if the bucket is empty [6]. Retries are funded by the work that is succeeding, so when failures spike and successes stop, the bucket drains and retries stop with it [7].

Python's problem is ergonomics. The reflexive tenacity decorator, stop_after_attempt(4) with exponential wait, gives every wrapped function its own independent four attempts, so five call sites produce a fleet ceiling of 5x4 with nothing coordinating them [8] - twenty attempts where the operator believes there are four [1]. The post's fix is to make the budget an object the retry predicate consults rather than a constant baked into the decorator, with a worked example at capacity 40 and refill 1.0 tokens per second and a 0.1-token success bonus [9]. On those numbers an empty bucket takes 40 seconds to refill [2] and it takes ten successful calls to fund a single retry [3]. The language-specific trap, per the author, is forgetting the threading.Lock: under a thread pool the read-modify-write on the token count races, and a budget of 40 quietly admits many more retries under exactly the load spike it was built for [10]. Under asyncio the lock and the sleep change type but the shape does not [11].

Go tempts the other way. The retry loop is so explicit that people reinvent it per package with subtly different backoff [12]. The idiom the author endorses is to pass the shared budget alongside context.Context, letting the context own the deadline and the budget own the count, with the same sync.Mutex guarding the bucket [13][14]. The Go trap is named as the select between the backoff timer and ctx.Done(), where cancellation is meant to beat the retry [15][16].

What to watch: the source material supplied here cuts off mid-sentence in the Go section, so the JavaScript promise-chain trap named in the headline is not in the excerpt [17]. If you are porting this, the check worth running first is whether your budget object is one instance or N, and whether the read-modify-write is guarded.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories