Taking the Claude API to Production: Errors, Rate Limits, Cost Tracking, OpenTelemetry and the IChatClient Bridge
When you take the Claude API to production you separate the SDK's typed exceptions starting with the most specific, keep retries in one layer (the SDK), tell the spend cap apart from rate limits, and produce a cost metric from each response's usage. The SDK 13.8 error hierarchy, 529 and 429 retry behavior, double retries with Polly, the cache's effect on ITPM, OpenTelemetry spans and the IChatClient bridge that ignores temperature.
Version note: The code in this post was built with .NET 10, Anthropic C# SDK 13.8.0, Microsoft.Extensions.AI 11.0.0, OpenTelemetry .NET 1.21.0 and Claude API version 2026-10-21, and its unit tests were run. The error, retry, span and IChatClient tests use the SDK's real client with a fake HttpMessageHandler that never touches the network; the spend cap error comes from the documented example, and the other error bodies are derived from the documented error shape. Limits, prices and behavior were taken on 15 November 2026 from the platform.claude.com documentation and the CHANGELOG in the SDK's GitHub repository. This post is based on the official documentation.
Guess First
The first morning of the November sales. Traffic to the shopping assistant we wrote for Bookstore, the online bookstore used as the shared example across this site's series, goes up tenfold. Gül from support writes that customers are seeing "an unexpected error occurred". The logs are full of 429s and 529s. Emre's code has a single catch (Exception), and a Polly retry has been added to the HttpClient as well. At the end of the month Selin sees an unexpected jump on the bill, and nobody can say which feature spent what.
What is your guess? For one 529, how many HTTP requests actually go out? Which errors are worth retrying and which are a waste to retry? And how do you see the cost of an answer without waiting for the bill in the Console? In the last post of the series we answer these questions: the SDK's exception hierarchy and retry behavior, rate limits, per-request cost, OpenTelemetry, and the IChatClient bridge that connects Claude to Microsoft.Extensions.AI.
The SDK's Error Hierarchy
The C# SDK page gives the exceptions thrown per HTTP status code in a table: AnthropicBadRequestException for 400, AnthropicUnauthorizedException for 401, their own types for 403, 404, 422 and 429, and Anthropic5xxException for every 5xx. All 4xx exceptions derive from Anthropic4xxException, and their common base is AnthropicApiException. Besides these there are three more types: the AnthropicSseException from the fourth post for errors inside a stream after a 200 response, AnthropicIOException for network errors, and AnthropicInvalidDataException for responses that lack an expected field.
According to the SDK's CHANGELOG, version 13.5.0 (4 November 2026) extended this hierarchy: separate exceptions for specific status codes and API error codes, plus RequestID and WorkspaceID on AnthropicApiException. Looking at 13.8.0 with reflection, we saw that AnthropicOverloadedException for 529 derives from Anthropic5xxException, and that AnthropicEnforcedSpendLimitReachedException for the monthly spend cap and AnthropicRampLimitExceededException for the ramp limit derive from AnthropicRateLimitException. This distinction matters, because all three arrive with the same 429 and the same rate_limit_error type but behave entirely differently.
In Bookstore we wrote a small classifier that turns exceptions into a decision:
{
// Most specific types first: the spend-cap and ramp-limit exceptions derive from AnthropicRateLimitException.
public static ClaudeFailure Classify(Exception exception) => exception switch
{
AnthropicEnforcedSpendLimitReachedException e => From(e, ClaudeFailureKind.SpendCapReached, retryLater: false),
AnthropicRateLimitException e => From(e, ClaudeFailureKind.RateLimited, retryLater: true),
AnthropicOverloadedException e => From(e, ClaudeFailureKind.Overloaded, retryLater: true),
Anthropic5xxException e => From(e, ClaudeFailureKind.ServerError, retryLater: true),
AnthropicUnauthorizedException or AnthropicForbiddenException => From((AnthropicApiException)exception, ClaudeFailureKind.Unauthorized, retryLater: false),
Anthropic4xxException e => From(e, ClaudeFailureKind.InvalidRequest, retryLater: false),
AnthropicSseException => new(ClaudeFailureKind.StreamInterrupted, RetryLater: true, null, null, null),
AnthropicIOException => new(ClaudeFailureKind.Network, RetryLater: true, null, null, null),
_ => new(ClaudeFailureKind.Unexpected, RetryLater: false, null, null, null),
};
private static ClaudeFailure From(AnthropicApiException e, ClaudeFailureKind kind, bool retryLater) =>
new(kind, retryLater, (int)e.StatusCode, e.RequestID, e.ErrorCode);
}
The order matters: most specific types first. If we do not catch the spend cap before the general rate limit branch, we produce an answer that says "wait a little and try again"; according to the documentation, however, this error lasts until the first day of the next month.
Retries: What Does the SDK Retry?
According to the SDK page, the client retries 2 times by default, with a short exponential backoff. What is retried: connection errors, 408, 409, 429 and 5xx. The errors page adds that the SDK honors the retry-after header. The default timeout is 10 minutes; both can be changed on the client with MaxRetries and Timeout, or for a single call with WithOptions. Since the first post we have read these values from ClaudeOptions.
We tested this behavior with the fake handler. When 529 comes twice and the third response is 200, the SDK sends three requests and adds a header to each one that says which attempt it is:
[Fact]
public async Task Overloaded_IsRetriedBySdkUntilItSucceeds()
{
FakeClaudeHandler handler = new(
Error((HttpStatusCode)529, Overloaded, "req_test_529"),
Error((HttpStatusCode)529, Overloaded, "req_test_529"),
new FakeResponse(Ok));
AnthropicClient client = new() { ApiKey = "fake-api-key-for-tests", HttpClient = new HttpClient(handler), MaxRetries = 2 };
Message message = await Send(client);
Assert.Equal("msg_test_ok", message.ID);
Assert.Equal(3, handler.Requests.Count);
Assert.Equal(["0", "1", "2"], handler.Requests.Select(r => r.Headers.GetValues("x-stainless-retry-count").Single()));
}
A 400 is never retried; in the test a single request goes out. The real surprise is the spend cap:
[Fact]
public async Task SpendCap_IsStillRetriedBySdkWithoutRetryAfter()
{
FakeClaudeHandler handler = new(
Error(HttpStatusCode.TooManyRequests, SpendCap, "req_018EeWyXxfu5pfWkrYcMdjWG"),
Error(HttpStatusCode.TooManyRequests, SpendCap, "req_018EeWyXxfu5pfWkrYcMdjWG"),
Error(HttpStatusCode.TooManyRequests, SpendCap, "req_018EeWyXxfu5pfWkrYcMdjWG"));
AnthropicClient client = new() { ApiKey = "fake-api-key-for-tests", HttpClient = new HttpClient(handler), MaxRetries = 2 };
await Assert.ThrowsAsync<Anthropic.Models.AnthropicEnforcedSpendLimitReachedException>(() => Send(client));
// Every 429 is retried; this one keeps failing until the next month.
Assert.Equal(3, handler.Requests.Count);
}
According to the documentation, the spend cap 429 carries no retry-after header, and every attempt, including the SDK's automatic retries, fails until the end of the month. The test confirms it: with MaxRetries = 2, three requests go out and then AnthropicEnforcedSpendLimitReachedException arrives. These extra requests are free but wasted; the application's own retry layer must stop when it sees this exception.
This is exactly where the double retry pitfall with Polly sits. If you also add the standard resilience handler from the third post of the External Service Integration and Resilience series to the HttpClient the SDK uses, the two layers multiply: the handler retries each HTTP request by its own rules, and the SDK retries each error that comes back from the handler. In a storm of 529s, a single user request can turn into a dozen HTTP requests, which pushes the rate limit even harder. The rule is simple: retries live in a single layer. The SDK's retry is usually the right place because it knows Claude's headers; use Polly only after turning the SDK's retry off with MaxRetries = 0. A circuit breaker is a separate question: the SDK has none, and it makes sense in the application layer to stop calling Claude at all while it keeps returning 529.
Rate Limits and the Spend Cap
According to the rate limits page, limits are set at the organization level and depend on the usage tier. Since 26 June 2026 there are three tiers: Start, Build and Scale; each has a monthly spend cap (500, 1,000 and 200,000 USD). For the Messages API, limits are measured per model class as RPM, ITPM and OTPM and enforced with the token bucket algorithm; capacity is not reset at fixed intervals but replenished continuously. 60 requests per minute can be enforced as 1 request per second; short bursts can exceed the limit.
An important detail connects to the seventh post: ITPM counts only uncached input tokens. cache_read_input_tokens do not count toward the limit; a well-working prompt cache therefore lowers both cost and rate limit pressure. The ramp limit is a separate mechanism: when usage grows quickly, you can get a 429 even while you are under ITPM and OTPM. The documentation suggests starting bulk jobs at a low rate and spreading the load; the Message Batches API from the eighth post is not subject to ramp limits.
Every response carries anthropic-ratelimit-requests-remaining, anthropic-ratelimit-tokens-remaining and the matching -reset headers. The SDK does not put them on the exception; reading them needs client.WithRawResponse, as in the documentation. You can also set your own limits for workspaces below the organization limit; separating the support bot and the nightly batch job into different workspaces keeps one from starving the other. When you reach a spend limit you set yourself, the API returns 400, not 429.
Turning Errors into API Responses
When services do not catch the exception, ASP.NET Core's exception handler steps in. We wrote an IExceptionHandler specific to Claude:
// Turns SDK exceptions that escaped a service into ProblemDetails; the SDK has already retried by then.
public sealed class ClaudeExceptionHandler(IProblemDetailsService problemDetails, ILogger<ClaudeExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
{
if (exception is not AnthropicException)
{
return false;
}
ClaudeFailure failure = ClaudeFailureClassifier.Classify(exception);
// The request id is what Anthropic support asks for; log it with every failure.
logger.LogError(exception, "Claude call failed: kind={Kind} status={Status} requestId={RequestId} errorCode={ErrorCode}",
failure.Kind, failure.StatusCode, failure.RequestId, failure.ErrorCode);
context.Response.StatusCode = failure.RetryLater ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status502BadGateway;
if (failure.RetryLater)
{
context.Response.Headers.RetryAfter = "30";
}
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
Exception = exception,
ProblemDetails =
{
Title = failure.RetryLater ? "The assistant is busy. Try again shortly." : "The assistant is not available.",
Extensions = { ["kind"] = failure.Kind.ToString() },
},
});
}
}
Transient errors (rate limit, overload, 5xx, network) return 503 with Retry-After, permanent ones 502. The spend cap counts as permanent; nothing changes when the customer tries again, and the team must be alerted. The log line includes RequestID; according to the documentation every response carries a request-id header, and Anthropic support finds a request by this id. In the spend cap test, the id and the error code are read correctly from the exception:
[Fact]
public async Task SpendCap_IsItsOwnExceptionAndIsNotWorthRetrying()
{
FakeClaudeHandler handler = new(Error(HttpStatusCode.TooManyRequests, SpendCap, "req_018EeWyXxfu5pfWkrYcMdjWG"));
Exception error = await Assert.ThrowsAnyAsync<AnthropicRateLimitException>(() => Send(handler.CreateClient()));
ClaudeFailure failure = ClaudeFailureClassifier.Classify(error);
Assert.IsType<Anthropic.Models.AnthropicEnforcedSpendLimitReachedException>(error);
Assert.Equal(new ClaudeFailure(ClaudeFailureKind.SpendCapReached, false, 429, "req_018EeWyXxfu5pfWkrYcMdjWG",
"enforced_spend_limit_reached"), failure);
}
Cost Tracking
Seeing the bill at the end of the month is not enough; you need to know at the request level which feature spent what. ClaudePricing from the third post already estimated the cost from each response's usage. We connected it to a .NET Meter:
// Per-feature token and cost counters next to the SDK's spans; the bill is checked against the Console cost page.
public sealed class ClaudeUsageMeter : IDisposable
{
public const string MeterName = "Bookstore.Claude";
private readonly Meter _meter;
private readonly Counter<long> _tokens;
private readonly Counter<double> _cost;
public ClaudeUsageMeter(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);
_tokens = _meter.CreateCounter<long>("bookstore.claude.tokens", unit: "{token}");
_cost = _meter.CreateCounter<double>("bookstore.claude.cost", unit: "USD");
}
public decimal Record(string feature, string model, TokenUsage usage)
{
KeyValuePair<string, object?> featureTag = new("feature", feature);
KeyValuePair<string, object?> modelTag = new("model", model);
_tokens.Add(usage.Input, featureTag, modelTag, new("type", "input"));
_tokens.Add(usage.CacheRead, featureTag, modelTag, new("type", "cache_read"));
_tokens.Add(usage.CacheWrite, featureTag, modelTag, new("type", "cache_write"));
_tokens.Add(usage.Output, featureTag, modelTag, new("type", "output"));
decimal usd = ClaudePricing.KnownModels.Contains(model) ? ClaudePricing.EstimateUsd(model, usage) : 0m;
_cost.Add((double)usd, featureTag, modelTag);
return usd;
}
public void Dispose() => _meter.Dispose();
}
The support service calls Record("support", ...) after each response and also writes the estimated cost to its log line. The counters leave through OpenTelemetry with feature and model tags; "what did the support bot spend this week" becomes a dashboard query. This is an estimate: the price table lives in our code and must be updated when prices change.
The real numbers are at Anthropic. The Usage and Cost pages in the Console are one route. The programmatic route is the Usage and Cost Analytics API, released in beta on 29 October 2026: with the analytics-usage-cost-2026-08-24 beta header, the /v1/organizations/analytics/usage and /cost endpoints return tokens and USD cost by model, workspace and API key. This API is called with an Admin API key, not a normal API key; the documentation suggests giving it to a separate service, keeping it in a secrets manager and setting an expiry. There is also a delay: according to the documentation, the data typically refreshes within 4 hours and can take up to 24 hours. Your own counters are immediate, Anthropic's numbers are exact; compare the two regularly.
OpenTelemetry
According to the SDK page, when you add the SDK's sources to the tracer provider with AddSource("com.anthropic.sdk.csharp*"), a span is produced for each request. The registration in Program.cs:
builder.Services.AddClaude(builder.Configuration);
builder.Services.AddExceptionHandler<ClaudeExceptionHandler>();
builder.Services.AddSingleton<ClaudeUsageMeter>();
builder.Services.AddClaudeChatClient();
// Spans and metrics go to an OTLP collector only when one is configured (OTEL_EXPORTER_OTLP_ENDPOINT).
bool exportOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddSource("com.anthropic.sdk.csharp*").AddSource("Bookstore.ChatClient").AddAspNetCoreInstrumentation();
if (exportOtlp)
{
tracing.AddOtlpExporter();
}
})
.WithMetrics(metrics =>
{
metrics.AddMeter(ClaudeUsageMeter.MeterName);
if (exportOtlp)
{
metrics.AddOtlpExporter();
}
});
The span we caught with an ActivityListener after sending one request through the fake handler looks like this:
span source=com.anthropic.sdk.csharp name=anthropic.messages.create kind=Client status=Ok
tags: gen_ai.provider.name=anthropic, server.address=api.anthropic.com, server.port=443, http.request.method=POST, http.route=/v1/messages, gen_ai.operation.name=chat, gen_ai.request.model=claude-haiku-5-5, gen_ai.request.max_tokens=10, url.scheme=https, url.path=/v1/messages, url.full=https://api.anthropic.com/v1/messages, anthropic.request.id=req_test_ok, http.response.status_code=200, gen_ai.usage.input_tokens=5, gen_ai.response.id=msg_ok, gen_ai.response.model=claude-haiku-5-5, gen_ai.usage.output_tokens=1, anthropic.message.stop_reason=end_turn, http.request.resend_count=1
The span name is anthropic.messages.create; the tags follow the GenAI semantic conventions from the eighth post of the AI with .NET series (gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.response.id), alongside anthropic.request.id and http.request.resend_count. Retries are not separate spans but the resend_count value of one span. Our test confirms that the SDK also sends the traceparent header with the request; according to the documentation, with API-side tracing (in beta since 14 October 2026) also set up, a request's SDK and API spans appear in one trace.
One default in the documentation matters for security: SDK spans carry only metadata by default. Prompt and response text goes into the span only when the content mode is explicitly set to content. Before you move customer messages into an observability platform, think about that platform's access and retention settings.
The IChatClient Bridge
The SDK implements the IChatClient interface of Microsoft.Extensions.AI. That lets Claude join the pipeline we built in the AI with .NET series: function invocation, caching and telemetry middleware, and the tools of the MCP C# SDK. The registration:
public static class ChatClientRegistration
{
// Provider-independent features use IChatClient; Claude-specific ones (caching breakpoints, effort, skills, batches)
// keep using IAnthropicClient directly.
public static IServiceCollection AddClaudeChatClient(this IServiceCollection services)
{
services.AddChatClient(sp =>
{
ClaudeOptions options = sp.GetRequiredService<IOptions<ClaudeOptions>>().Value;
return sp.GetRequiredService<IAnthropicClient>().AsIChatClient(options.Model);
})
.UseFunctionInvocation()
.UseOpenTelemetry(sourceName: "Bookstore.ChatClient");
return services;
}
}
When to use which? IChatClient for features where you want to stay provider-independent and that are plain chat or tool calls. The native SDK when you need something specific to Claude: the cache breakpoints from the seventh post, effort and adaptive thinking, structured outputs' output_config.format, batches, the Files API, skills, the MCP connector and Managed Agents. Bookstore uses both side by side; there are two separate registrations in DI.
One behavior of the bridge is silent:
[Fact]
public async Task ChatClient_IgnoresTemperatureForThisApiVersion()
{
FakeClaudeHandler handler = new(Ok);
IChatClient chat = handler.CreateClient().AsIChatClient("claude-haiku-5-5");
ChatResponse response = await chat.GetResponseAsync("Where is order 1042?", new ChatOptions { Temperature = 0.2f, MaxOutputTokens = 50 });
Assert.Equal("Dune ships tomorrow.", response.Text);
JsonElement body = JsonDocument.Parse(handler.RequestBodies[0]).RootElement;
Assert.False(body.TryGetProperty("temperature", out _));
Assert.Equal(50, body.GetProperty("max_tokens").GetInt32());
}
ChatOptions.Temperature is accepted without an error, but there is no temperature in the request. As we saw in the first post, API version 2026-10-21 removed the sampling parameters; that is why the SDK 13 adapter ignores them. Code moved over from another provider that relies on temperature behaves differently without an error. Use it knowingly.
All the tests:
Passed! - Failed: 0, Passed: 58, Skipped: 0, Total: 58, Duration: 2 s - Bookstore.Api.Tests.dll (net10.0)
In Production
Let us close the series with a checklist. The client is a singleton, the key is in a secret store (01). Every response's stop_reason and usage are read (02). Model and effort are chosen with an evaluation set (03). In streams, the CancellationToken is passed to the SDK (04). Authorization for tools lives in code (05). A business rule check follows structured output (06). The prompt cache hit ratio is tracked (07). Bulk work goes into batches, files are uploaded once (08). Skill and MCP tokens have the least privilege (09). Long-running work uses a pinned agent version and a budget (10). And from this post: a single retry layer, a separate alert for the spend cap, a per-request cost metric, logs with request ids, and metadata-only spans.
Common Pitfalls
Catching every exception with one catch. Rate limits, the spend cap and a broken request reach the user with the same message, and none of them is handled correctly.
Leaving retries unlimited or in two layers. The SDK already retries; every layer on top multiplies the number of requests.
Taking the spend cap for a rate limit. Both are 429; waiting fixes one and does not fix the other.
Doing rate limit math from input_tokens. Tokens read from the cache do not count toward ITPM; the real number is different.
Putting prompts into spans. Check the observability platform's personal data policy before turning on content mode.
Believing you set temperature through IChatClient. It has no effect on this API version.
A Small Experiment
★☆☆ Add a separate branch for AnthropicRampLimitExceededException to ClaudeFailureClassifier. Write what the ramp limit description on the rate limits page recommends in this case into a test, not into a code comment.
★★☆ Send the support service's request with client.WithRawResponse.Messages.Create, read the anthropic-ratelimit-tokens-remaining header and write it to a gauge metric. Test it by adding the header to the fake handler.
★★★ Add a middleware to the IChatClient pipeline that reads each response's UsageDetails and calls ClaudeUsageMeter.Record, so that calls through the native SDK and through IChatClient add up in the same cost metric. Confirm with a test that both give the same number.
This is where the series ends. In eleven posts we connected Claude to Bookstore.Api from the first request to production: the Messages API, model and effort, streaming, tool use, structured outputs, prompt caching, batches and the Files API, skills and the MCP connector, Managed Agents, and today's layer of errors, cost and observability. The code is in the repository with a separate tag at the end of every post. The API changes fast; every fact in this series is based on documentation dated 15 November 2026. When a new version comes out, read the release notes and the SDK's CHANGELOG first, then run the tests.