Table of Contents

Retries and timeouts

What is retried

Unary reads (Get*, List*, and Health) that fail with Unavailable are retried, up to four attempts in all, with exponential backoff and jitter. Writes are never retried by default, because a lost response could repeat a side effect. Retries stop at the call's deadline and when the caller cancels. Streaming calls are not retried: Watch.WatchAsync reconnects on its own, and a broken exec or forward stream surfaces its error.

RetryPolicy controls this through OpenShellClientOptions.Retry:

var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Retry = RetryPolicy.Default with
    {
        MaxAttempts = 6,                          // the first attempt plus five retries
        MaxBackoff = TimeSpan.FromSeconds(10),    // a gateway RetryInfo delay above this ends the retries
    },
    DefaultTimeout = TimeSpan.FromSeconds(30),    // deadline for each unary call, retries included
});
Setting Default
MaxAttempts 4 (the first attempt included; 1 disables retries)
InitialBackoff 100 ms
MaxBackoff 5 s
BackoffMultiplier 2
Jitter 0.2 (the fraction of each delay that is randomized)
RetryableStatusCodes Unavailable
IsIdempotent reads: Get*, List*, Health

OpenShellClientOptions.DefaultTimeout sets a deadline for unary calls that do not set their own; null (the default) means none.

Server-suggested delays and MUTATION_LOCK_TIMEOUT

When the gateway attaches a retry delay (google.rpc.RetryInfo), the next attempt waits at least that long. A delay longer than MaxBackoff ends the retries and surfaces the error, with the delay in OpenShellException.RetryDelay.

The gateway does this, for example, when a mutation waited 10 seconds for the lock of a sandbox or workspace that another mutation held: the call fails with Unavailable, an ErrorInfo whose Reason is MUTATION_LOCK_TIMEOUT, and a retry delay. Because the failed call is a write, the SDK does not repeat it; decide in your code whether repeating it is safe:

try
{
    await client.Sandboxes.StopAsync("agent-1");
}
catch (OpenShellException e) when (e.ErrorInfo?.Reason == "MUTATION_LOCK_TIMEOUT")
{
    // Another mutation held the sandbox lock for longer than the gateway waits (10 s). The gateway
    // suggests when to try again; whether repeating this write is safe is your decision.
    await Task.Delay(e.RetryDelay ?? TimeSpan.FromSeconds(1));
    await client.Sandboxes.StopAsync("agent-1");
}

RetryDelay says when to try again, not whether repeating a mutation is safe.

Retrying writes

To retry a write you have made safe to repeat, for example one that always carries a request id, widen IsIdempotent. It receives the gRPC method name, such as GetSandbox or CreateSandbox. Use RetryPolicy.None to turn retries off.

// Writes are not retried by default. Widen IsIdempotent for methods you make safe to repeat, for
// example by always sending a request id. It receives the gRPC method name.
var client = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Retry = RetryPolicy.Default with
    {
        IsIdempotent = method => method.StartsWith("Get", StringComparison.Ordinal)
            || method.StartsWith("List", StringComparison.Ordinal)
            || method is "Health" or "CreateSandbox",
    },
});

// Or turn retries off.
var noRetries = new OpenShellClient(new OpenShellClientOptions
{
    Address = new Uri("https://gateway.example.com"),
    Retry = RetryPolicy.None,
});

Most mutations accept a request id (RequestId on the options records, or a requestId parameter): the gateway answers a repeated request with the original result, which makes a retry safe.

Cancellation

Every method takes a CancellationToken. Cancelling it throws OperationCanceledException (not OpenShellException), also in the middle of retries and waits.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
    await client.Exec.RunAsync("agent-1", ["sleep", "60"], cancellationToken: cts.Token);
}
catch (OperationCanceledException)
{
    // Your own cancellation surfaces as OperationCanceledException, not OpenShellException.
}