mailboxrpc: honor the operator's retry-after hint - #1060
Conversation
In this commit, we teach the retry loop to prefer the operator's retry-after hint over the backoff it computes for itself. Until now a shed request cost the caller a jittered exponential wait chosen with no knowledge of when the operator would actually serve it again. That guess is expensive to get wrong. An operator answers only the first shed in each window, because every response costs it a durable mailbox write, so a caller that comes back too early is dropped in silence and learns nothing at all. It then guesses again, from the same starting point, and burns its whole deadline that way. The hint is better information than anything this side can compute, since it comes from the token bucket that shed the request. A caller that honors it comes back exactly once, when it can be served. It rides as a standard google.rpc.RetryInfo detail on the gRPC status, which the mailbox already carries end to end in the response's error header, so reading it needs no new wire contract. It is still a number chosen by the other side of the connection, so we clamp it to RetryPolicy.MaxRetryAfter, thirty seconds by default. That is comfortably past any honest bucket's refill and comfortably short of an outage, so a hint above it is a bug or an attack either way and gets the ceiling instead. A hint that is absent, non-positive, or outside the protobuf duration range is not a very long wait but a malformed one, and falls through to the jittered backoff exactly as before.
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
@claude review this |
|
Claude finished @Roasbeef's task in 2m 16s —— View job Review: mailboxrpc — honor the operator's retry-after hint
Overall this is a clean, well-reasoned change. The logic is correct, the "untrusted number" handling is careful, the doc comments explain the why, and the test coverage is thorough (extract/wrap/absent/malformed/non-positive/clamp/fallback/normalize). The What I verified
Observations (non-blocking)
None of these require action to merge. Nice work — the design notes in the doc comments and PR body make the reasoning easy to follow. |
In this PR, we teach the mailbox retry helper to wait as long as the operator
asks when it sheds a request, instead of falling back on a delay we picked
ourselves.
Retryalready backs off onResourceExhaustedwith jittered exponentialbackoff, which is the right shape when we know nothing. But the operator does
know something: its rate limiter can say exactly when the next token arrives.
The lumos side now attaches that as a
google.rpc.RetryInfodetail(lightninglabs/lumos#726), and this reads it. A client that returns too early
gets shed again, and because the operator can only afford to answer roughly one
shed per second, that second shed is silent, so the client burns a full deadline
learning nothing. Honoring the hint is what turns an ongoing loop into a single
wasted round trip.
The hint rides on the status detail rather than an envelope header on purpose.
DecodeErrorHeadersalready reconstructs the status, soRetryAfterreads itoff the error the retry loop is already holding. A bare header would never reach
here: the
callclosure returns only anerror, and the transport drops therest of the headers, so we would have had to plumb them through the facade for
this one field.
Treating the hint as untrusted
A retry-after is a number from someone else, so
backoffFordoes not take it atface value. Three cases, each deliberately different rather than folded together:
An absurd but well-formed hint, say a hundred years, is clamped to
RetryPolicy.MaxRetryAfter(30s by default) instead of ignored. A hostile orbuggy operator gets to slow us down, which is its right, but not to park us
forever.
A malformed hint, meaning a duration outside the protobuf range or a detail we
cannot resolve, is treated as no hint at all and falls through to the computed
backoff. Garbage should not be honored, and it should not be clamped either,
because clamping garbage silently invents a number.
A non-positive hint is also treated as no hint. This one matters most: honoring
a zero would collapse the backoff into a hot loop aimed at a server that just
told us it is overloaded, which is worse than having no hint at all.
The clamped hint is used without jitter, which is a departure from the computed
path. Jitter exists to break up a fleet-wide schedule, and there is no fleet-wide
schedule here: the hint comes from the caller's own per-client bucket, so two
clients already get different values. Jittering downward would just return early
and defeat the point.
TestRetryClampsHostileServerHintholds the ceiling. Deleting the clamp makes ithang past its timeout rather than fail an assertion, which is the honest
signature of what the bug would be.
A rebase note
This began stacked on the idempotency-key work in #1056, now merged. Rebasing it
onto main hit one conflict: that branch deleted
DefaultRetryPolicyas deadcode, and this branch had added
MaxRetryAfterto it. We kept the deletion, anddropped the one test assertion that called it. The property it checked, that the
default ceiling is applied, is already asserted two lines above through
RetryPolicy{}.normalize(), which is the documented zero-value contract and thething production actually goes through.
Scope
This is the client half of a cheap interim, not a fix. The underlying problem is
that a shed response costs a durable mailbox write, which is why the operator
rate-limits how often it can afford to answer at all. Removing that constraint
needs a non-durable delivery class, tracked in #1054. Until then this makes the
answers we do get more useful, and it does nothing for the sheds that stay
silent.
go test ./mailbox/... -race,make fmt-changed,make lint-changed-local(0 issues) and
make commitmsg-lintare all clean.