# gRPC Client Resilience in ASP.NET Core: Retries, Timeouts, and Circuit Breakers

The first time a healthy gRPC service took down a dependent API I was on call for, the root cause was almost insulting: a rolling deployment closed a few connections for two seconds, and every in-flight call threw `RpcException` with `StatusCode.Unavailable`. No retries, no fallback, just a wall of 500s. gRPC client resilience in ASP.NET Core is what separates a service that shrugs off a two-second blip from one that pages you at 2am, and getting it right in 2026 means more than wrapping every call in a `try-catch`. This walkthrough covers the three real options - built-in gRPC retries, Polly, and the standard resilience handler - and, just as importantly, when each one quietly does nothing.

If you like learning from working code rather than fragments, the complete, annotated resilience setup I use in production - with the retry policies, timeout wiring, and circuit-breaker configuration all connected in one runnable project - goes deeper on [Patreon](https://www.patreon.com/CodingDroplets), ready to clone and adapt. Resilience is also one of those topics that only truly clicks when you see it wired into a full API alongside rate limiting and health checks: the same building blocks - Polly's `AddStandardResilienceHandler`, circuit breakers, and knowing when *not* to retry - are covered inside a complete production codebase in Chapter 10 of the [ASP.NET Core Web API: Zero to Production course](https://aspnetcoreapi.codingdroplets.com/).

[![ASP.NET Core Web API: Zero to Production](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

## Why gRPC Calls Fail in Production (and Why a Try-Catch Is Not Enough)

gRPC calls fail for the same boring reasons every network call fails: momentary loss of connectivity, a service that is briefly unavailable during a deploy, or a timeout under load. The .NET client surfaces these as an `RpcException` carrying a `StatusCode`. The one you will see most is `Unavailable`, which is explicitly the "transient, safe to retry" signal.

The naive fix is a hand-rolled loop:

```csharp
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
{
    // retry... somehow
}
```

Duplicating that across every call site is verbose and, in my experience, always drifts. One team retries three times, another retries forever, a third retries a `POST`\-style unary call that was not safe to repeat. The better answer is to configure resilience once, declaratively, at the channel or client-factory level - and to understand the gRPC-specific rules that decide whether a retry actually happens.

## What Is the Best Way to Add Resilience to a gRPC Client?

For most ASP.NET Core apps, configure **built-in gRPC retries** through the channel's service config for transient-fault retrying, add a **per-call deadline** for timeouts, and layer a **circuit breaker** (via the standard resilience handler) when you call a dependency that can stay down long enough to be worth failing fast. Here is how the three approaches compare:

| Approach | Operates at | Best for | Watch out for |
| --- | --- | --- | --- |
| Built-in gRPC retries (`ServiceConfig`) | gRPC status-code layer | Transient retries that understand gRPC semantics | Streaming and committed-call limits |
| Polly via `AddPolicyHandler` | HTTP message layer | Legacy setups already standardized on Polly | gRPC returns HTTP 200 even on errors |
| `AddStandardResilienceHandler` | HTTP message layer (pipeline) | Circuit breaker, timeout, and rate limiting in one | Needs `Grpc.Net.ClientFactory` 2.64.0+ |

The rest of this article builds each one up and shows where the sharp edges are.

## Approach 1: Built-In gRPC Retries via Service Config

The .NET gRPC client has native retry support (requires `Grpc.Net.Client` 2.36.0 or later). Unlike an HTTP-layer policy, it understands gRPC status codes, so it retries on the right signals. You configure it once when the channel is created:

```csharp
var retryMethodConfig = new MethodConfig
{
    Names = { MethodName.Default }, // applies to every method on the channel
    RetryPolicy = new RetryPolicy
    {
        MaxAttempts = 4,
        InitialBackoff = TimeSpan.FromSeconds(1),
        MaxBackoff = TimeSpan.FromSeconds(5),
        BackoffMultiplier = 1.5,
        RetryableStatusCodes = { StatusCode.Unavailable }
    }
};

builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
        o.Address = new Uri("https://catalog.internal:5001"))
    .ConfigureChannel(c =>
        c.ServiceConfig = new ServiceConfig { MethodConfigs = { retryMethodConfig } });
```

A few specifics that matter in practice:

*   `RetryableStatusCodes` **is a deliberate allow-list.** Start with `Unavailable`. Only add codes like `DeadlineExceeded` when you are certain the call is idempotent. I have watched a well-meaning `Internal` in that list turn one bad request into four.
    
*   `MaxAttempts` **includes the original call** and is capped by `GrpcChannelOptions.MaxRetryAttempts` (default 5). A value of 4 means the original plus three retries.
    
*   **Backoff is randomized.** The actual delay is a random value between 0 and the current backoff, which is then multiplied by `BackoffMultiplier` after each attempt. That jitter is intentional - it stops a fleet of clients from retrying in lockstep and dog-piling a recovering server.
    

### When Do Built-In gRPC Retries Actually Fire?

A call is retried only while all of these hold: the failing status is in `RetryableStatusCodes`, the attempt count is below `MaxAttempts`, the deadline has not passed, and the call has **not been committed**. That last rule is the one that surprises people. A call commits - and can never be retried - once the client receives response headers, or once its outgoing messages exceed the client buffer (`MaxRetryBufferSize` and `MaxRetryBufferPerCallSize` on the channel).

The practical fallout is around streaming. Server-streaming and bidirectional calls will not retry after the first message has been received, and client-streaming calls will not retry once buffered outgoing messages exceed the limit. For long-lived streams you still need manual re-establishment logic; retries are a unary-call feature in all but the simplest cases. If you want to confirm a retry happened, the client automatically sends `grpc-previous-rpc-attempts` metadata with the attempt count. The full commit rules and option reference live in the official [Microsoft Learn guide on gRPC transient fault handling](https://learn.microsoft.com/en-us/aspnet/core/grpc/retries).

For a deeper mental model of where gRPC fits against REST for these inter-service calls in the first place, the trade-offs are laid out in [gRPC vs REST in .NET Microservices](https://codingdroplets.com/grpc-vs-rest-dotnet-microservices-performance-debuggability-team-productivity).

## Approach 2: Polly with AddGrpcClient and AddPolicyHandler

Because `AddGrpcClient` returns an `IHttpClientBuilder`, you *can* attach Polly with `AddPolicyHandler`, and plenty of older guides show exactly that. It works - but only if you understand one gRPC-specific trap that most of those posts (many written for ASP.NET Core 3.1) never mention.

**gRPC almost always returns HTTP status 200, even when the RPC failed.** The failure is carried in a `grpc-status` trailer, not the HTTP status line. So a Polly policy built on `HandleTransientHttpError` - which keys off HTTP status codes - sees a 200 and concludes everything is fine. Your "retry policy" silently never retries.

```csharp
// This looks right and does almost nothing for gRPC:
.AddPolicyHandler(HttpPolicyExtensions
    .HandleTransientHttpError()   // matches 5xx/408 HTTP codes, not grpc-status
    .WaitAndRetryAsync(3, n => TimeSpan.FromSeconds(n)));
```

There is a second, subtler issue: an HTTP-handler policy sits below the gRPC layer, so if the connection is never established, the message handler may not be invoked the way you expect. My rule of thumb after debugging this more than once: **do not use raw** `AddPolicyHandler` **for gRPC retry logic.** Use built-in gRPC retries for the retry concern, and reserve Polly for cross-cutting HTTP concerns where it genuinely applies. If you are standardizing Polly across your services, the patterns and when-to-use guidance are collected in [Polly Resilience Patterns for ASP.NET Core Enterprise](https://codingdroplets.com/polly-resilience-patterns-for-asp-net-core-enterprise-a-decision-guide).

## Approach 3: AddStandardResilienceHandler (the 2026 Default for Everything Else)

For circuit breaking, timeouts, and a rate limiter bundled into one opinionated pipeline, the modern answer is `AddStandardResilienceHandler` from `Microsoft.Extensions.Http.Resilience` (10.x), documented in [Microsoft's guide to building resilient HTTP apps](https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience). It gives you a sensible default stack - total timeout, retry, circuit breaker, and per-attempt timeout - with one call:

```csharp
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
        o.Address = new Uri("https://catalog.internal:5001"))
    .AddStandardResilienceHandler();
```

One hard-won version note: if you enable the standard resilience or hedging handler on a gRPC client while running `Grpc.Net.ClientFactory` 2.63.0 or earlier, you can hit a runtime exception. Upgrade to `Grpc.Net.ClientFactory` 2.64.0 or later. This bit me during a package bump, and the error message is not obvious about the cause.

Because this handler also operates at the HTTP layer, I treat its **retry** stage the same way I treat Polly here - the HTTP-200 caveat applies - and let built-in gRPC retries own the retry decision. What the standard handler earns its place for is the **circuit breaker and timeout** stages, which are HTTP-transport concerns and work well for gRPC.

## Adding Timeouts: Deadlines Are the gRPC-Native Way

The most important timeout in gRPC is not a Polly timeout - it is a **deadline**, passed per call. A deadline is an absolute point in time by which the call must complete, and it propagates across service hops, which a client-side cancellation token alone does not.

```csharp
var reply = await client.SayHelloAsync(
    new HelloRequest { Name = "Coding Droplets" },
    deadline: DateTime.UtcNow.AddSeconds(5));
```

Set a deadline on every call. Without one, a stuck server can hold a client thread until something else gives out, and no retry policy saves you because the call never returns to be retried. Deadlines also interact correctly with built-in retries: once the deadline passes, retries stop. For broader guidance on layering request-level timeouts across an API, see the [ASP.NET Core Request Timeout Strategy guide](https://codingdroplets.com/aspnet-core-request-timeout-strategy-enterprise-decision-guide).

## Circuit Breakers: Failing Fast When a Dependency Is Down

Retries handle a two-second blip. They make things *worse* when a dependency is down for two minutes, because every caller keeps hammering it. That is what a circuit breaker is for: after a threshold of failures it "opens" and fails calls instantly for a cooldown window, giving the downstream service room to recover instead of drowning it in retries.

With gRPC clients, the pragmatic setup is to get the circuit breaker from `AddStandardResilienceHandler` (its breaker stage watches the outbound HTTP calls) while keeping retry logic in the gRPC service config. Conceptually the breaker sits *outside* the retry loop: retries exhaust first, repeated exhaustion trips the breaker. If you want the underlying state machine - closed, open, half-open - explained from the ground up, I wrote it up in [The Circuit Breaker Pattern in ASP.NET Core](https://codingdroplets.com/circuit-breaker-pattern-aspnet-core).

## Putting It Together: Which Should You Use When?

Here is the decision I actually apply, stripped to its essentials:

1.  **Every unary call gets a deadline.** No exceptions. This is the cheapest, highest-value line of resilience code you will write.
    
2.  **Use built-in gRPC retries (**`ServiceConfig` **+** `RetryPolicy`**) for the retry concern**, with `RetryableStatusCodes` limited to `Unavailable` unless the method is provably idempotent.
    
3.  **Add** `AddStandardResilienceHandler` **for the circuit breaker and transport timeout**, and remember `Grpc.Net.ClientFactory` must be 2.64.0+.
    
4.  **Reach for hedging only for read-only, idempotent methods** that are latency-sensitive - it sends parallel attempts and takes the first success, which is powerful but wasteful, and cannot be combined with a retry policy on the same method.
    
5.  **Do not rely on** `AddPolicyHandler`**/**`HandleTransientHttpError` **to retry gRPC failures** - the HTTP 200 masks the real status.
    

## Common Mistakes I See in Production

*   **Retrying non-idempotent unary calls.** A `CreateOrder` RPC that is not deduplicated can create four orders on four attempts. Make the operation idempotent first, then retry.
    
*   **No jitter, or hand-rolled fixed-delay retries.** Synchronized retries cause a thundering herd on the recovering server. The built-in policy's randomized backoff exists specifically to prevent this.
    
*   **Expecting streaming calls to retry.** They largely do not once the first message flows. Build explicit reconnect logic for long-lived streams.
    
*   **Leaving** `MaxAttempts` **and deadlines unbounded together.** Retries plus no deadline is how a "resilient" client hangs indefinitely.
    

## Frequently Asked Questions

### Does AddPolicyHandler work for retrying gRPC calls?

Not reliably. `AddGrpcClient` exposes `AddPolicyHandler` because it returns an `IHttpClientBuilder`, but gRPC returns HTTP status 200 even when the RPC fails - the real result is in the `grpc-status` trailer. A Polly policy based on `HandleTransientHttpError` sees the 200 and never retries. Use built-in gRPC retries via `ServiceConfig` for retrying, and reserve Polly or the standard resilience handler for circuit breaking and timeouts.

### What status codes should be in RetryableStatusCodes for a gRPC retry policy?

Start with `StatusCode.Unavailable` only, which is the canonical transient-fault signal. Add `DeadlineExceeded` or `Internal` only for methods you are certain are idempotent, because those codes can indicate the server may have already processed the request. A narrow allow-list is safer than a broad one.

### How do I add a timeout to a gRPC client in ASP.NET Core?

Use a per-call deadline: pass `deadline: DateTime.UtcNow.AddSeconds(n)` to the call. Deadlines are gRPC-native, propagate across service hops, and stop retries once they expire. You can additionally set a transport timeout via `AddStandardResilienceHandler`, but the per-call deadline is the primary control.

### Why do my gRPC streaming calls not retry?

By design. A call commits - and becomes non-retryable - once the client receives response headers or its buffered outgoing messages exceed `MaxRetryBufferSize`. Server and bidirectional streams commit after the first received message, so they will not retry beyond it. Long-lived streams need manual re-establishment logic rather than the built-in retry policy.

### Do I need a specific package version for gRPC resilience in .NET 10?

Built-in gRPC retries need `Grpc.Net.Client` 2.36.0 or later. If you use `AddStandardResilienceHandler` (from `Microsoft.Extensions.Http.Resilience`) with a gRPC client, upgrade `Grpc.Net.ClientFactory` to 2.64.0 or later - earlier versions can throw a runtime exception when the resilience or hedging handler is enabled.

* * *

## About the Author

I'm Celin Daniel, Co-founder of [Coding Droplets](https://codingdroplets.com/). I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

*   GitHub: [codingdroplets](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
