Section 01
The loop as it is written today
The shape most tutorials teach is: Get, reconcile, then Forget+Done on success or AddRateLimited+Done on failure. The semantics are right. The spelling in current controller-runtime differs in three ways that matter.
func (c *Controller[request]) processNextWorkItem(ctx context.Context) bool {
obj, priority, shutdown := c.Queue.GetWithPriority()
if shutdown {
return false
}
// We call Done here so the workqueue knows we have finished
// processing this item. ...
defer c.Queue.Done(obj)
ctrlmetrics.ActiveWorkers.WithLabelValues(c.Name).Add(1)
defer ctrlmetrics.ActiveWorkers.WithLabelValues(c.Name).Add(-1)
c.reconcileHandler(ctx, obj, priority)
return true
}
Doneis deferred, registered before any reconciling happens. It runs on success, on error, and on panic. It is not a branch of the switch.- The queue is a priority queue —
GetWithPriority, notGet.UsePriorityQueuedefaults totrue. - There is a
TerminalErrorpath that suppresses the retry entirely, which the classic shape has no equivalent for.
Section 02
The four-way switch
Everything the controller decides after your Reconcile returns happens in one switch. The asymmetry worth noticing is which arms call Forget.
| Reconcile returned | Queue action | Forget? | Why |
|---|---|---|---|
error wrapping TerminalError | none | no | Retrying cannot fix it; metrics only |
| any other error | AddWithOpts{RateLimited} | no | Preserving the failure count is what grows the backoff |
RequeueAfter > 0 | AddWithOpts{After} | yes | A success — reset backoff, then schedule |
| success | none | yes | Next failure starts from 5ms again |
TerminalError is detected with errors.Is, which means it survives wrapping — a practical detail worth confirming rather than assuming.
plain error errors.Is(err, TerminalError(nil)) = false requeued? true TerminalError errors.Is(err, TerminalError(nil)) = true requeued? false TerminalError wrapped with %w errors.Is(err, TerminalError(nil)) = true requeued? false nil errors.Is(err, TerminalError(nil)) = false requeued? false TerminalError message : terminal error: spec.replicas must be >= 0 Unwrap() : spec.replicas must be >= 0
Returning both a non-nil error and a RequeueAfter is not a fifth case — the error wins, the requeue hint is silently discarded, and the controller logs a warning saying so. If you have ever written return ctrl.Result{RequeueAfter: time.Minute}, err expecting a one-minute retry, you got exponential backoff instead.
Section 03
The retry curve a failing object actually experiences
With the priority queue enabled — the default — the rate limiter is NewTypedItemExponentialFailureRateLimiter(5ms, 1000s). Reading the delays straight off it:
failure delay cumulative 1 5ms 0s 2 10ms 0s 4 40ms 0s 8 640ms 1s 10 2.56s 5s 12 10.24s 20s 14 40.96s 1m22s 16 2m43.84s 5m28s 18 10m55.36s 21m51s 19 16m40s 38m31s 20 16m40s 55m11s 22 16m40s 1h28m31s first failure hitting the 1000s cap: #19
Forget.Reaching the ceiling is fast: nineteen consecutive failures, and the whole journey takes well under an hour of wall-clock time. Staying there is slow: once capped, a broken object is retried every 16 minutes 40 seconds, indefinitely.
So if a dependency is down for twenty minutes and then recovers, the objects that were failing throughout do not recover promptly with it — each waits out up to a further 16m40s before anyone tries again. The controller looks wedged long after the actual fault is gone.
The reset is Forget, and it is total: measured at 16m40s before, 5ms after. Any successful reconcile for that key returns it to the bottom of the curve.
With the priority queue disabled you get DefaultTypedControllerRateLimiter, which is the maximum of the same per-item exponential curve and an overall token bucket (10 qps, 100 burst). Measured, its first six delays are identical (5ms, 10ms, 20ms, 40ms, 80ms, 160ms) because the bucket has burst left; the bucket only asserts itself once a controller is failing across many keys at once.
Section 04
What Forget actually touches
The name is the problem. Forget sounds like removal. The doc comment is explicit that it is not.
// Forget indicates that an item is finished being retried. Doesn't matter whether it's for perm failing // or for success, we'll stop the rate limiter from tracking it. This only clears the `rateLimiter`, you // still have to call `Done` on the queue. Forget(item T)
1. Forget() does not remove anything from the queue
after Add("a") Len()=1
after Forget("a") Len()=1 <- still queued
after Get()+Done() Len()=0
Forget reaches one counter. Done reaches the two sets from chapter 04 — and is the only call that can return a key to the queue. Neither substitutes for the other, and only one of them is safe to omit.Section 05
The silent stall
The failure mode follows directly. Get puts the key in processing. Only Done takes it out. Miss it, and every future event for that object lands in dirty and stays there — because, as chapter 04 measured, an add for a key that is currently processing is forbidden from touching the queue.
2. CORRECT: Get -> Forget -> Done. The key can be processed again.
re-added and retrieved: "obj" <- healthy
3. BUG: Get -> Forget, but Done() is never called.
100 further Add("obj") calls, then Len()=0
Get() timed out after 500ms: the key is NEVER returned again
=> this object has silently stopped reconciling. No error. No log.
defer is the whole defence
This is precisely why controller-runtime registers defer c.Queue.Done(obj) immediately after Get, before any work happens. An early return added to a reconcile path years later cannot skip it, and neither can a panic. The symmetrical mistake — calling Done and forgetting Forget — is far more benign: the rate limiter tracks that key's failures forever, which wastes memory and inflates later backoff, but the object keeps reconciling.
One of these two omissions is a memory leak. The other is an object that silently leaves the control loop. They are not symmetric, and only one of them is protected by the language.
Section 06
Further reading
All links returned HTTP 200 on 2026-09-11 unless noted.
Section 07
Closing note — what varies, and what was not verified
Durable versus run-specific
- Durable: the backoff doubling from 5 ms; the cap first reached at failure #19;
Forgetresetting the delay to 5 ms;Len()unchanged byForget; the stall in part 3. All are deterministic arithmetic or set operations with no timing dependence. - Run-specific: only the 500 ms timeout used to demonstrate the stall — the stall itself is permanent, and the timeout is just how long the demo is willing to wait before declaring it.
Traps this chapter had to avoid
- A demo that hangs instead of reporting. Part 3 deliberately creates a queue from which
Getnever returns. CallingGetdirectly would hang the program forever and look like a broken demo rather than a finding. It is wrapped in a goroutine with a timeout so the stall is reported. - Reading the wrong rate limiter. controller-runtime picks a different default depending on
UsePriorityQueue. MeasuringDefaultTypedControllerRateLimiterand presenting it as “the default” would be wrong for a stock controller, which uses the plain exponential limiter. Both are measured in §03. - Assuming
TerminalErrorbreaks under wrapping. It is detected witherrors.Is, so%wis safe — worth testing rather than guessing, since the opposite would be a common and silent bug.
Not verified
- The priority queue's ordering behaviour.
GetWithPriority,AddWithOptsand the priority semantics were read in source but not exercised; §03 measures the rate limiter controller-runtime pairs with it, not the queue itself. - End-to-end through a manager. No
Manager,Controlleror envtest was started. The switch in §02 is quoted from source (rung 2); only theTerminalErrorpredicate inside it was executed. - Metrics.
ReconcileTotal,ReconcileErrors,TerminalReconcileErrorsandActiveWorkersappear in the quoted code; none were registered or observed. - Panic behaviour. The claim that deferred
Donealso runs on panic follows from Go semantics andRecoverPanicexists as an option, but no panicking reconciler was run here.
Section 08
Spoken drills
What is the difference between Forget and Done, and which one is safe to forget?
A strong answer hits
Forgetclears only the rate limiter's failure count — it does not dequeue and does not mark work completeDoneremoves the key fromprocessingand re-queues it if still dirty- Missing
Doneis fatal: measured as 100 adds,Len()==0,Getnever returns - Missing
Forgetis a leak: failures tracked forever, later backoff inflated, but the object keeps reconciling - The honest half: they are not symmetric, which is exactly why controller-runtime defers
Doneunconditionally and callsForgeton only two of four arms
check against §04 and §05
A dependency was down for 20 minutes. It is back, but your controller still is not converging objects. Nothing is erroring now. Explain, with numbers.
A strong answer hits
- Consecutive failures drove each key up the exponential curve, 5 ms doubling
- The 1000 s cap is reached at failure #19 — comfortably inside a 20-minute outage
- Each affected key now waits up to 16m40s before its next attempt
- Only a successful reconcile calls
Forgetand resets it to 5 ms - The honest half: this is working as designed — the backoff is what stopped you hammering a dead dependency. If recovery time matters more than protection, that is an argument for a custom rate limiter with a lower ceiling, not for removing the backoff
check against §03
Why does the error arm deliberately not call Forget?
A strong answer hits
- The failure count is what makes the next delay longer than the last
Forgeton the error path would pin every retry at 5 ms — an unthrottled hot loop- The
RequeueAfterarm does call it, because that return is a success, not a failure - The honest half: it means a key that alternates success and failure never accumulates backoff, which is usually right but does let a flapping object retry aggressively forever
check against §02 and §03
You return ctrl.Result{RequeueAfter: time.Minute}, err with a non-nil err. What happens?
A strong answer hits
- The error arm wins; the
RequeueAfteris discarded entirely - You get rate-limited exponential backoff, not a one-minute retry
- A warning is logged saying exactly this — it is the only visible symptom
- If you want a fixed retry interval, return a nil error and set
RequeueAfter - The honest half: returning the error is usually still the right call, because it is what surfaces in
ReconcileErrors— the fix is to stop expecting the interval, not to swallow the error
check against §02