thesis / grpc-retry
Configurable retry interceptors (unary, stream) for the thesis/grpc-client, with backoff and per-status retry policies.
Fund package maintenance!
Requires
- php: ^8.4
- amphp/amp: ^3.1
- thesis/googleapis-rpc-types: ^0.1.6
- thesis/grpc-client: ^0.1.9
- thesis/grpc-protocol: ^0.1.9
Requires (Dev)
- symfony/var-dumper: ^8.0
- testo/assert: ^0.1.11
- testo/test: ^0.1.6
- testo/testo: ^0.10.29
This package is auto-updated.
Last update: 2026-08-03 18:11:56 UTC
README
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
Installation
composer require thesis/grpc-retry
Usage
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
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\Exponential
Exponentially 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\Fixed
The same delay before every retry:
use Thesis\Grpc\Retry\Backoff\Fixed; new Fixed(0.25); // always wait 250ms
Custom
Implement Backoff for any other schedule (decorrelated jitter, a fixed table, no delay at all).
How it works
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.
- Unary (
interceptUnary) re-invokes the call in a loop: on anInvokeErrorwhose status is retryable it waits forbackoff->delay(...)and tries again, until the call succeeds or the attempt budget is exhausted, at which point the last error is rethrown. - Stream (
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.
Streaming caveats
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 A6
The 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:
- Commit point. A6 commits when the client receives Response-Headers; this library commits on the first received message (or on a buffer overflow), because the interceptor layer sees messages, not the header event. A retry can therefore still happen in the narrow window after headers but before the first message.
- Buffer bound. A6 leaves the unit unspecified; this library bounds the buffer by message count
(
maxBufferedMessages), since it holds unserialized messages. - Not implemented: retry throttling (token bucket), server pushback (
grpc-retry-pushback-ms), and transparent retries.