Configurable retry interceptors (unary, stream) for the thesis/grpc-client, with backoff and per-status retry policies.
Retry middleware for thesis/grpc-client: unary and streaming interceptors that transparently re-issue a failed call while its gRPC status is retryable and the attempt budget is not exhausted, pausing between attempts according to a configurable backoff.
Contents Installationcomposer require thesis/grpc-retryUsage
Retry\Interceptor implements both client interceptor contracts, so a single instance can be
registered on both chains: the unary chain retries request → response calls, the stream chain
retries streams. Register it on whichever chains you need.
use Thesis\Grpc\Client; use Thesis\Grpc\Retry; $retry = new Retry\Interceptor(new Retry\Config(maxAttempts: 5)); $client = new Client\Builder() ->withUnaryInterceptors($retry) ->withStreamInterceptors($retry) ->build();Configuration
Retry\Config is an immutable dto:
use Google\Rpc\Code; use Thesis\Grpc\Retry\Backoff; use Thesis\Grpc\Retry\Config; new Config( maxAttempts: 3, // total attempts, including the initial call retryableCodes: [Code::UNAVAILABLE], // statuses that make a failed call eligible for a retry backoff: new Backoff\Exponential(), // pause between attempts );
| Option | Default | Meaning |
|---|---|---|
maxAttempts |
3 |
Total number of attempts. 3 means the initial call plus two retries. |
retryableCodes |
[Code::UNAVAILABLE] |
A retry happens only when the failure carries one of these statuses. |
backoff |
Backoff\Exponential |
How long to wait before each retry. |
maxBufferedMessages |
256 |
How many client messages a stream may buffer for a possible replay. |
Transport-level failures (a refused or dropped connection) are surfaced by the client as
InvokeError with Code::UNAVAILABLE, so the default policy already covers them. Add more codes
(e.g. Code::RESOURCE_EXHAUSTED, Code::DEADLINE_EXCEEDED) only for calls where retrying them is
safe.
Backoff decides how long to pause before the n-th retry:
interface Backoff { /** * @param positive-int $attempt * @return float */ public function delay(int $attempt): float; }
Backoff\ExponentialExponentially growing delay, capped at max, with random jitter to avoid a thundering herd of
clients retrying in lockstep:
use Thesis\Grpc\Retry\Backoff\Exponential; new Exponential( base: 0.1, // delay before the first retry, in seconds factor: 2.0, // multiplier per attempt: 0.1 → 0.2 → 0.4 → ... max: 30.0, // upper bound for a single delay jitter: 0.2, // ±20% random deviation );
Jitter is drawn from an injectable Random\Randomizer, so tests can pass a seeded engine for a
deterministic delay:
new Exponential(randomizer: new Random\Randomizer(new Random\Engine\Mt19937(seed: 42)));
Backoff\FixedThe same delay before every retry:
use Thesis\Grpc\Retry\Backoff\Fixed; new Fixed(0.25); // always wait 250msCustom
Implement Backoff for any other schedule (decorrelated jitter, a fixed table, no delay at all).
Both interceptors are endpoint-agnostic: failover is handled by the transport. Every re-issue reuses
the call's PickContext, so the load balancer skips the endpoint that just failed whenever it has an
alternative. Waits between attempts honour the call's Cancellation: a cancelled call or an expired
deadline aborts the wait instead of sleeping it out.
interceptUnary) re-invokes the call in a loop: on an InvokeError whose status is
retryable it waits for backoff->delay(...) and tries again, until the call succeeds or the
attempt budget is exhausted, at which point the last error is rethrown.interceptStream) wraps the stream in a decorator that buffers the client's messages
and the half-close, and re-opens the stream on a retryable failure, replaying the buffer onto the
fresh stream.A stream can only be retried while no server message has been observed yet. As soon as receive()
or iteration yields a value, the call is committed: any later failure is surfaced to the caller
unchanged, because replaying it could duplicate already-processed responses. Until then, every sent
message is buffered in memory so it can be replayed. That buffer is bounded by maxBufferedMessages:
a client- or bidirectional stream that sends more than that before the first response simply stops
being retryable (a partial replay would be wrong) and releases the buffer, so memory stays bounded.
Because retries replay the request, only enable them for calls that are safe to execute more than once. Retrying is inherently safe for reads; for writes it requires idempotency (e.g. an idempotency key or a conditional update).
Relation to gRFC A6The buffer/replay/commit model follows gRFC A6 "client retries":
outgoing messages are buffered so a fresh attempt can replay them, and the call commits — retries
stop and the buffer is freed — once it can no longer be safely replayed. The backoff formula
(min(base * factor^(n-1), max) * random(0.8, 1.2)) and the retryableStatusCodes check come from
A6 as well.
It is a deliberate subset, adapted to what a client interceptor can observe:
maxBufferedMessages), since it holds unserialized messages.grpc-retry-pushback-ms),
and transparent retries.| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | thesis/grpc-auth | 0 | 17.78 | 03-08-2026 |
| 2 | thesis/grpc-deadline | 0 | 43.33 | 03-08-2026 |
| 3 | thesis/grpc-logging | 0 | 35 | 03-08-2026 |
| 4 | karelwintersky/arris.config | 0 | 10 | 03-08-2026 |
| 5 | juicychat-client 0.2.26 | 0 | 5 | 20-07-2026 |
| 6 | mobius-idempotence-library 0.2.1 | 0 | 5 | 10-07-2026 |
| 7 | httpr 0.5.2 | 0 | 5 | 20-07-2026 |
| 8 | patera 0.119.0 | 0 | 5 | 10-07-2026 |
| 9 | mobius-idempotence-lib 0.0.1 | 0 | 5 | 10-07-2026 |
| 10 | requestguard 0.1.6 | 0 | 5 | 20-07-2026 |