thesis/grpc-retry

Configurable retry interceptors (unary, stream) for the thesis/grpc-client, with backoff and per-status retry policies.

Maintainers

Package info

github.com/thesis-php/grpc-retry

pkg:composer/thesis/grpc-retry

Transparency log

Fund package maintenance!

www.tinkoff.ru/cf/5MqZQas2dk7

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.x-dev 2026-08-03 10:14 UTC

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 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.
  • 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.