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, 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.
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:
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:
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:
RetryableStatusCodesis a deliberate allow-list. Start withUnavailable. Only add codes likeDeadlineExceededwhen you are certain the call is idempotent. I have watched a well-meaningInternalin that list turn one bad request into four.MaxAttemptsincludes the original call and is capped byGrpcChannelOptions.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
BackoffMultiplierafter 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.
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.
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.
// 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.
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. It gives you a sensible default stack - total timeout, retry, circuit breaker, and per-attempt timeout - with one call:
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.
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.
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.
Putting It Together: Which Should You Use When?
Here is the decision I actually apply, stripped to its essentials:
Every unary call gets a deadline. No exceptions. This is the cheapest, highest-value line of resilience code you will write.
Use built-in gRPC retries (
ServiceConfig+RetryPolicy) for the retry concern, withRetryableStatusCodeslimited toUnavailableunless the method is provably idempotent.Add
AddStandardResilienceHandlerfor the circuit breaker and transport timeout, and rememberGrpc.Net.ClientFactorymust be 2.64.0+.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.
Do not rely on
AddPolicyHandler/HandleTransientHttpErrorto retry gRPC failures - the HTTP 200 masks the real status.
Common Mistakes I See in Production
Retrying non-idempotent unary calls. A
CreateOrderRPC 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
MaxAttemptsand 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. 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
YouTube: Coding Droplets
Website: codingdroplets.com






