diff --git a/Anthropic.SDK.Tests/Logging.cs b/Anthropic.SDK.Tests/Logging.cs new file mode 100644 index 0000000..f520bf8 --- /dev/null +++ b/Anthropic.SDK.Tests/Logging.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Anthropic.SDK.Constants; +using Anthropic.SDK.Messaging; + +namespace Anthropic.SDK.Tests +{ + [TestClass] + public class Logging + { + [TestMethod] + public async Task TestLoggingInterceptor() + { + var client = new AnthropicClient(requestInterceptor: new MyCustomInterceptor()); + var messages = new List(); + messages.Add(new Message(RoleType.User, "Write me a sonnet about the Statue of Liberty")); + var parameters = new MessageParameters() + { + Messages = messages, + MaxTokens = 512, + Model = AnthropicModels.Claude4Sonnet, + Stream = false, + Temperature = 1.0m, + }; + var res = await client.Messages.GetClaudeMessageAsync(parameters); + Assert.IsNotNull(res.Message.ToString()); + } + + [TestMethod] + public async Task TestLoggingInterceptorStreaming() + { + var client = new AnthropicClient(requestInterceptor: new MyCustomInterceptor()); + var messages = new List(); + messages.Add(new Message(RoleType.User, "Write me a sonnet about the Statue of Liberty")); + var parameters = new MessageParameters() + { + Messages = messages, + MaxTokens = 512, + Model = AnthropicModels.Claude4Sonnet, + Stream = true, + Temperature = 1.0m, + }; + var outputs = new List(); + await foreach (var res in client.Messages.StreamClaudeMessageAsync(parameters)) + { + if (res.Delta != null) + { + Debug.Write(res.Delta.Text); + } + + outputs.Add(res); + } + } + } + + public class MyCustomInterceptor : IRequestInterceptor + { + public async Task InvokeAsync( + HttpRequestMessage request, + Func> next, + CancellationToken cancellationToken = default) + { + // Custom logic before the request + Debug.WriteLine($"Sending request to {request.RequestUri}"); + + // Execute the request + var response = await next(request, cancellationToken); + + // Custom logic after the request + Debug.WriteLine($"Received response: {response.StatusCode}"); + + return response; + } + } +} diff --git a/Anthropic.SDK/AnthropicClient.cs b/Anthropic.SDK/AnthropicClient.cs index fc2d66a..d9b5f2f 100644 --- a/Anthropic.SDK/AnthropicClient.cs +++ b/Anthropic.SDK/AnthropicClient.cs @@ -45,6 +45,17 @@ public class AnthropicClient : IDisposable /// internal HttpClient HttpClient { get; set; } + /// + /// Optional request interceptor for custom retry logic, logging, or other cross-cutting concerns. + /// + private readonly IRequestInterceptor? _requestInterceptor; + + /// + /// Gets the request interceptor if one was provided. + /// Used internally by endpoints to intercept HTTP requests. + /// + internal IRequestInterceptor? RequestInterceptor => _requestInterceptor; + /// /// Creates a new entry point to the Anthropic API, handling auth and allowing access to the various API endpoints /// @@ -54,15 +65,35 @@ public class AnthropicClient : IDisposable /// potentially loading from environment vars. /// /// A . + /// + /// Optional request interceptor for advanced scenarios like custom retry logic, logging, or auditing. + /// Most users don't need this - the SDK works perfectly without it. + /// Only provide this if you need to intercept and customize HTTP requests before they're sent. + /// /// + /// /// implements to manage the lifecycle of the resources it uses, including . /// When you initialize , it will create an internal instance if one is not provided. /// This internal HttpClient is disposed of when AnthropicClient is disposed of. /// If you provide an external HttpClient instance to AnthropicClient, you are responsible for managing its disposal. + /// + /// + /// Request Interceptor: This is an advanced feature for scenarios requiring custom request handling. + /// Common use cases include: + /// + /// Custom retry logic beyond what HttpClient provides + /// Special logging/tracing requirements + /// Company-specific audit requirements + /// Request/response modification or inspection + /// + /// If you're already handling resilience at the HttpClient level (via Polly, HttpClientFactory policies, etc.), + /// you typically don't need a request interceptor. + /// /// - public AnthropicClient(APIAuthentication apiKeys = null, HttpClient client = null) + public AnthropicClient(APIAuthentication apiKeys = null, HttpClient client = null, IRequestInterceptor requestInterceptor = null) { HttpClient = SetupClient(client); + _requestInterceptor = requestInterceptor; this.Auth = apiKeys.ThisOrDefault(); Messages = new MessagesEndpoint(this); Batches = new BatchesEndpoint(this); diff --git a/Anthropic.SDK/BaseEndpoint.cs b/Anthropic.SDK/BaseEndpoint.cs index a7599bf..6b83815 100644 --- a/Anthropic.SDK/BaseEndpoint.cs +++ b/Anthropic.SDK/BaseEndpoint.cs @@ -32,6 +32,11 @@ public abstract class BaseEndpoint /// protected abstract HttpClient GetClient(); + /// + /// Gets the optional request interceptor for adding custom logic to HTTP requests. + /// + protected abstract IRequestInterceptor GetRequestInterceptor(); + /// /// Helper method to read the response content as a string. /// @@ -152,10 +157,28 @@ protected async Task HttpRequestRaw(string url = null, Http } } - response = await GetClient().SendAsync(req, + // Use interceptor if provided, otherwise make direct HTTP call + var interceptor = GetRequestInterceptor(); + if (interceptor != null) + { + response = await interceptor.InvokeAsync( + req, + (request, ct) => GetClient().SendAsync( + request, + streaming ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead, + ct), + ctx) + .ConfigureAwait(false); + } + else + { + // Default path - no interception + response = await GetClient().SendAsync( + req, streaming ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead, ctx) - .ConfigureAwait(false); + .ConfigureAwait(false); + } if (response.IsSuccessStatusCode) { diff --git a/Anthropic.SDK/EndpointBase.cs b/Anthropic.SDK/EndpointBase.cs index 01addcd..c381d01 100644 --- a/Anthropic.SDK/EndpointBase.cs +++ b/Anthropic.SDK/EndpointBase.cs @@ -91,6 +91,14 @@ protected override HttpClient GetClient() return client; } + /// + /// Gets the optional request interceptor for adding custom logic to HTTP requests. + /// + protected override IRequestInterceptor GetRequestInterceptor() + { + return Client.RequestInterceptor; + } + private static void AddHeaderIfNotPresent(HttpRequestHeaders headers, string name, string value) { if (!headers.Contains(name)) diff --git a/Anthropic.SDK/Examples/LoggingInterceptor.cs b/Anthropic.SDK/Examples/LoggingInterceptor.cs new file mode 100644 index 0000000..e1c5d82 --- /dev/null +++ b/Anthropic.SDK/Examples/LoggingInterceptor.cs @@ -0,0 +1,205 @@ +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Anthropic.SDK.Examples +{ + /// + /// Example implementation of IRequestInterceptor that logs HTTP request and response details. + /// This is a reference implementation showing how to add logging, metrics, and diagnostics. + /// + /// + /// This interceptor logs request/response information for debugging and monitoring purposes. + /// It measures request duration and captures status codes, URLs, and optional request/response bodies. + /// + /// Usage: + /// + /// var loggingInterceptor = new LoggingInterceptor( + /// logRequestBody: true, + /// logResponseBody: true + /// ); + /// + /// var client = new AnthropicClient( + /// apiKeys: new APIAuthentication("your-api-key"), + /// requestInterceptor: loggingInterceptor + /// ); + /// + /// + public class LoggingInterceptor : IRequestInterceptor + { + private readonly bool _logRequestBody; + private readonly bool _logResponseBody; + + /// + /// Creates a new LoggingInterceptor with the specified logging options. + /// + /// Whether to log request body content (default: false) + /// Whether to log response body content (default: false) + public LoggingInterceptor( + bool logRequestBody = false, + bool logResponseBody = false) + { + _logRequestBody = logRequestBody; + _logResponseBody = logResponseBody; + } + + /// + /// Intercepts the HTTP request and logs request/response details. + /// + public async Task InvokeAsync( + HttpRequestMessage request, + Func> next, + CancellationToken cancellationToken = default) + { + var requestId = Guid.NewGuid().ToString("N").Substring(0, 8); + var stopwatch = Stopwatch.StartNew(); + + try + { + // Log request + await LogRequestAsync(requestId, request, cancellationToken).ConfigureAwait(false); + + // Execute the request + var response = await next(request, cancellationToken).ConfigureAwait(false); + + stopwatch.Stop(); + + // Log response + await LogResponseAsync(requestId, response, stopwatch.ElapsedMilliseconds, cancellationToken).ConfigureAwait(false); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + LogException(requestId, ex, stopwatch.ElapsedMilliseconds); + throw; + } + } + + /// + /// Logs HTTP request details. + /// + private async Task LogRequestAsync(string requestId, HttpRequestMessage request, CancellationToken cancellationToken) + { + var logMessage = $"[{requestId}] → {request.Method} {request.RequestUri}"; + + if (_logRequestBody && request.Content != null) + { + try + { + // Buffer content to memory so it can be read multiple times + await request.Content.LoadIntoBufferAsync().ConfigureAwait(false); + + var contentLength = request.Content.Headers.ContentLength ?? 0; + + // Only log bodies under 10KB to avoid performance issues + if (contentLength > 0 && contentLength < 10_000) + { +#if NET6_0_OR_GREATER + var content = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + var content = await request.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + logMessage += Environment.NewLine + $"Request Body ({contentLength} bytes): {content}"; + } + else if (contentLength == 0) + { + logMessage += Environment.NewLine + "Request Body: [Empty]"; + } + else if (contentLength >= 10_000) + { + logMessage += Environment.NewLine + $"Request Body: [Too large to log - {contentLength} bytes]"; + } + } + catch (Exception ex) + { + logMessage += Environment.NewLine + $"Request Body: [Failed to read - {ex.Message}]"; + } + } + + LogRequest(requestId, request.Method.Method, request.RequestUri?.ToString(), logMessage); + } + + /// + /// Logs HTTP response details. + /// + private async Task LogResponseAsync( + string requestId, + HttpResponseMessage response, + long elapsedMs, + CancellationToken cancellationToken) + { + var logMessage = $"[{requestId}] ← {(int)response.StatusCode} {response.ReasonPhrase} ({elapsedMs}ms)"; + + if (_logResponseBody && response.Content != null) + { + try + { + // Buffer content to memory so it can be read by both logger and caller + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + + var contentLength = response.Content.Headers.ContentLength ?? 0; + + // Only log bodies under 10KB + if (contentLength > 0 && contentLength < 10_000) + { +#if NET6_0_OR_GREATER + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + logMessage += Environment.NewLine + $"Response Body ({contentLength} bytes): {content}"; + } + else if (contentLength == 0) + { + logMessage += Environment.NewLine + "Response Body: [Empty]"; + } + else if (contentLength >= 10_000) + { + logMessage += Environment.NewLine + $"Response Body: [Too large to log - {contentLength} bytes]"; + } + } + catch (Exception ex) + { + // Don't fail the request if logging fails + logMessage += Environment.NewLine + $"Response Body: [Failed to read - {ex.Message}]"; + } + } + + LogResponse(requestId, (int)response.StatusCode, response.ReasonPhrase, elapsedMs, logMessage); + } + + /// + /// Logs exceptions that occur during request execution. + /// Override this method to implement custom logging. + /// + protected virtual void LogException(string requestId, Exception exception, long elapsedMs) + { + // Default: no logging. Override in derived class for custom logging. + // Example: Console.WriteLine($"[{requestId}] ✗ Exception after {elapsedMs}ms: {exception.Message}"); + } + + /// + /// Logs outgoing HTTP request. + /// Override this method to implement custom logging. + /// + protected virtual void LogRequest(string requestId, string method, string url, string fullMessage) + { + // Default: no logging. Override in derived class for custom logging. + // Example: Console.WriteLine(fullMessage); + } + + /// + /// Logs incoming HTTP response. + /// Override this method to implement custom logging. + /// + protected virtual void LogResponse(string requestId, int statusCode, string reasonPhrase, long elapsedMs, string fullMessage) + { + // Default: no logging. Override in derived class for custom logging. + // Example: Console.WriteLine(fullMessage); + } + } +} diff --git a/Anthropic.SDK/Examples/RetryInterceptor.cs b/Anthropic.SDK/Examples/RetryInterceptor.cs new file mode 100644 index 0000000..7d424cf --- /dev/null +++ b/Anthropic.SDK/Examples/RetryInterceptor.cs @@ -0,0 +1,239 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Anthropic.SDK.Examples +{ + /// + /// Example implementation of IRequestInterceptor that adds retry logic with exponential backoff. + /// This is a reference implementation showing how to handle transient failures. + /// + /// + /// This interceptor retries failed requests based on HTTP status codes and exceptions. + /// It uses exponential backoff to avoid overwhelming the server during outages. + /// + /// This interceptor clones HttpRequestMessage for each retry attempt. + /// Request content is buffered in memory before the first attempt, + /// so this may not be suitable for very large request bodies (>100MB). + /// For large uploads, consider implementing retries at the application level + /// or using a streaming-friendly approach. + /// + /// Usage: + /// + /// var retryInterceptor = new RetryInterceptor( + /// maxRetries: 3, + /// initialDelay: TimeSpan.FromSeconds(1) + /// ); + /// + /// var client = new AnthropicClient( + /// apiKeys: new APIAuthentication("your-api-key"), + /// requestInterceptor: retryInterceptor + /// ); + /// + /// + public class RetryInterceptor : IRequestInterceptor + { + private readonly int _maxRetries; + private readonly TimeSpan _initialDelay; + private readonly double _backoffMultiplier; + + /// + /// Creates a new RetryInterceptor with the specified retry configuration. + /// + /// Maximum number of retry attempts (default: 3) + /// Initial delay before first retry (default: 1 second) + /// Multiplier for exponential backoff (default: 2.0) + public RetryInterceptor( + int maxRetries = 3, + TimeSpan? initialDelay = null, + double backoffMultiplier = 2.0) + { + if (maxRetries < 0) + throw new ArgumentOutOfRangeException(nameof(maxRetries), "Max retries must be non-negative"); + + if (backoffMultiplier < 1.0) + throw new ArgumentOutOfRangeException(nameof(backoffMultiplier), "Backoff multiplier must be >= 1.0"); + + _maxRetries = maxRetries; + _initialDelay = initialDelay ?? TimeSpan.FromSeconds(1); + _backoffMultiplier = backoffMultiplier; + } + + /// + /// Intercepts the HTTP request and adds retry logic with exponential backoff. + /// + public async Task InvokeAsync( + HttpRequestMessage request, + Func> next, + CancellationToken cancellationToken = default) + { + var attempt = 0; + Exception lastException = null; + + // CAPTURE CONTENT ONCE BEFORE ANY ATTEMPTS + byte[] requestContent = null; + if (request.Content != null) + { +#if NET6_0_OR_GREATER + requestContent = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); +#else + requestContent = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false); +#endif + } + + while (attempt <= _maxRetries) + { + try + { + // Clone for EVERY attempt (including first) using pre-captured content + var requestToSend = CloneRequest(request, requestContent); + + var response = await next(requestToSend, cancellationToken).ConfigureAwait(false); + + // Check if we should retry based on status code + if (ShouldRetry(response.StatusCode, attempt)) + { + // Will dispose after this block + using (response) + { + // Capture diagnostics here +#if NET6_0_OR_GREATER + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + var errorBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + LogRetryAttempt(attempt, response.StatusCode, errorBody); + } + // response is now disposed + + await DelayBeforeRetry(attempt, cancellationToken).ConfigureAwait(false); + attempt++; + continue; + } + + // DON'T dispose - return to caller + return response; + } + catch (Exception ex) when (IsTransientException(ex, cancellationToken) && attempt < _maxRetries) + { + lastException = ex; + LogExceptionRetry(attempt, ex); + await DelayBeforeRetry(attempt, cancellationToken).ConfigureAwait(false); + attempt++; + } + } + + // Max retries exceeded + if (lastException != null) + { + throw new HttpRequestException( + $"Request failed after {_maxRetries} retry attempts. See inner exception for details.", + lastException); + } + + throw new HttpRequestException($"Request failed after {_maxRetries} retry attempts."); + } + + /// + /// Determines if a status code should trigger a retry. + /// + private bool ShouldRetry(HttpStatusCode statusCode, int currentAttempt) + { + if (currentAttempt >= _maxRetries) + return false; + + // Retry on specific status codes + return statusCode == HttpStatusCode.RequestTimeout || // 408 + statusCode == (HttpStatusCode)429 || // 429 TooManyRequests (not in netstandard2.0) + statusCode == HttpStatusCode.InternalServerError || // 500 + statusCode == HttpStatusCode.BadGateway || // 502 + statusCode == HttpStatusCode.ServiceUnavailable || // 503 + statusCode == HttpStatusCode.GatewayTimeout; // 504 + } + + /// + /// Determines if an exception is transient and should trigger a retry. + /// + private bool IsTransientException(Exception ex, CancellationToken cancellationToken) + { + // Network-level failures that are typically transient + return ex is HttpRequestException || + ex is TimeoutException || + // Only retry TaskCanceledException if it's NOT user-initiated cancellation + (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested); + } + + /// + /// Calculates and applies the delay before the next retry using exponential backoff. + /// + private async Task DelayBeforeRetry(int attemptNumber, CancellationToken cancellationToken) + { + var delay = TimeSpan.FromMilliseconds( + _initialDelay.TotalMilliseconds * Math.Pow(_backoffMultiplier, attemptNumber) + ); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + /// + /// Logs retry attempts due to HTTP status codes. + /// Override this method to implement custom logging. + /// + protected virtual void LogRetryAttempt(int attempt, HttpStatusCode statusCode, string errorBody) + { + // Default: no logging. Override in derived class for custom logging. + // Example: Console.WriteLine($"Retry attempt {attempt + 1}/{_maxRetries} - Status: {statusCode}"); + } + + /// + /// Logs retry attempts due to exceptions. + /// Override this method to implement custom logging. + /// + protected virtual void LogExceptionRetry(int attempt, Exception exception) + { + // Default: no logging. Override in derived class for custom logging. + // Example: Console.WriteLine($"Retry attempt {attempt + 1}/{_maxRetries} - Exception: {exception.Message}"); + } + + /// + /// Clones an HttpRequestMessage using pre-captured content. + /// + /// + /// This method creates a new request with the same properties as the original, + /// using content that was buffered before the first attempt. + /// This ensures that request content can be replayed for retry attempts. + /// + private HttpRequestMessage CloneRequest(HttpRequestMessage request, byte[] contentBytes) + { + var clone = new HttpRequestMessage(request.Method, request.RequestUri) + { + Version = request.Version + }; + + // Copy headers + foreach (var header in request.Headers) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + // Use pre-captured content + if (contentBytes != null && contentBytes.Length > 0) + { + clone.Content = new ByteArrayContent(contentBytes); + + // Copy content headers from original request + if (request.Content != null) + { + foreach (var header in request.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + return clone; + } + } +} diff --git a/Anthropic.SDK/IRequestInterceptor.cs b/Anthropic.SDK/IRequestInterceptor.cs new file mode 100644 index 0000000..793fa10 --- /dev/null +++ b/Anthropic.SDK/IRequestInterceptor.cs @@ -0,0 +1,33 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Anthropic.SDK +{ + /// + /// Interface for intercepting HTTP requests made by the Anthropic SDK. + /// Allows users to implement custom retry logic, logging, metrics, or other cross-cutting concerns. + /// + public interface IRequestInterceptor + { + /// + /// Intercepts an HTTP request and optionally wraps the call with custom logic. + /// + /// The HTTP request about to be sent + /// A delegate to invoke the next handler in the chain (or the actual HTTP call). + /// Takes the request and cancellation token as parameters. + /// Cancellation token for the operation + /// The HTTP response from the request + /// + /// This method allows you to add retry logic, circuit breakers, logging, metrics, or other cross-cutting concerns. + /// The interceptor is invoked for every HTTP request made by the SDK. + /// You must call the next delegate to proceed with the actual HTTP request. + /// The interceptor can modify the request before passing it to the next handler. + /// + Task InvokeAsync( + HttpRequestMessage request, + Func> next, + CancellationToken cancellationToken = default); + } +} diff --git a/Anthropic.SDK/VertexAIClient.cs b/Anthropic.SDK/VertexAIClient.cs index 076562e..7e4e805 100644 --- a/Anthropic.SDK/VertexAIClient.cs +++ b/Anthropic.SDK/VertexAIClient.cs @@ -28,6 +28,11 @@ public class VertexAIClient : IDisposable /// internal HttpClient HttpClient { get; set; } + /// + /// Optional request interceptor for adding custom logic (retry, logging, etc.) to HTTP requests. + /// + internal IRequestInterceptor RequestInterceptor { get; set; } + /// /// Creates a new entry point to the Anthropic API via Google Cloud Vertex AI /// @@ -37,15 +42,19 @@ public class VertexAIClient : IDisposable /// potentially loading from environment vars. /// /// A . + /// + /// Optional for adding custom logic (retry, logging, circuit breaker, etc.) to HTTP requests. + /// /// /// implements to manage the lifecycle of the resources it uses, including . /// When you initialize , it will create an internal instance if one is not provided. /// This internal HttpClient is disposed of when VertexAIClient is disposed of. /// If you provide an external HttpClient instance to VertexAIClient, you are responsible for managing its disposal. /// - public VertexAIClient(VertexAIAuthentication auth = null, HttpClient client = null) + public VertexAIClient(VertexAIAuthentication auth = null, HttpClient client = null, IRequestInterceptor requestInterceptor = null) { HttpClient = SetupClient(client); + RequestInterceptor = requestInterceptor; this.Auth = auth.ThisOrDefault(); Messages = new VertexAIMessagesEndpoint(this); } diff --git a/Anthropic.SDK/VertexAIEndpointBase.cs b/Anthropic.SDK/VertexAIEndpointBase.cs index 1d29725..efb5683 100644 --- a/Anthropic.SDK/VertexAIEndpointBase.cs +++ b/Anthropic.SDK/VertexAIEndpointBase.cs @@ -137,6 +137,14 @@ private static void AddHeaderIfNotPresent(HttpRequestHeaders headers, string nam } } + /// + /// Gets the optional request interceptor for adding custom logic to HTTP requests. + /// + protected override IRequestInterceptor GetRequestInterceptor() + { + return Client.RequestInterceptor; + } + /// /// Handle error responses from the API /// diff --git a/README.md b/README.md index 547ed37..879f392 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Anthropic.SDK is an unofficial C# client designed for interacting with the Claud - [Batching](#batching) - [Tools](#tools) - [Computer Use](#computer-use) + - [HTTP Resilience](#http-resilience) + - [Custom Retry and Logging with IRequestInterceptor](#custom-retry-and-logging-with-irequestinterceptor) - [Vertex AI Support](#vertex-ai-support) - [Authentication](#authentication) - [1. Environment Variables](#1-environment-variables) @@ -1122,6 +1124,291 @@ var tools = new List() }; ``` +### HTTP Resilience + +For production applications, it's recommended to add resilience patterns like retries, timeouts, and circuit breakers to handle transient failures when calling the Claude API. The SDK works seamlessly with .NET's standard resilience mechanisms. + +#### Using Microsoft.Extensions.Http.Resilience (.NET 8+) + +For .NET 8 and later, Microsoft provides a comprehensive resilience package: + +```bash +dotnet add package Microsoft.Extensions.Http.Resilience +``` + +**Quick Start: Standard Resilience Handler** + +The simplest approach is to use the built-in standard resilience handler with sensible defaults: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; + +var services = new ServiceCollection(); + +// Add AnthropicClient with standard resilience (includes retry, circuit breaker, timeouts) +services.AddHttpClient() + .AddStandardResilienceHandler(); + +var serviceProvider = services.BuildServiceProvider(); +var client = serviceProvider.GetRequiredService(); + +// Use the client - it now has built-in resilience +var messages = new List +{ + new Message(RoleType.User, "Hello, Claude!") +}; + +var parameters = new MessageParameters +{ + Messages = messages, + MaxTokens = 1024, + Model = AnthropicModels.Claude35Sonnet +}; + +var response = await client.Messages.GetClaudeMessageAsync(parameters); +``` + +**What you get out of the box:** +- **Rate Limiter**: Allows up to 1000 concurrent requests +- **Total Request Timeout**: 30 seconds for the entire request (including retries) +- **Retry**: Up to 3 attempts with exponential backoff and jitter (2 second base delay) +- **Circuit Breaker**: Opens if 10% of requests fail within 30 seconds (breaks for 5 seconds) +- **Attempt Timeout**: 10 seconds per individual request attempt + +**Customizing Standard Resilience** + +You can customize the default values to better suit the Claude API's rate limits and your application needs: + +```csharp +services.AddHttpClient() + .AddStandardResilienceHandler() + .Configure(options => + { + // Increase total timeout for longer conversations + options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(2); + + // Customize retry behavior + options.Retry.MaxRetryAttempts = 5; + options.Retry.Delay = TimeSpan.FromSeconds(1); + options.Retry.BackoffType = DelayBackoffType.Exponential; + options.Retry.UseJitter = true; + + // Adjust circuit breaker for Claude API + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(60); + options.CircuitBreaker.MinimumThroughput = 10; + options.CircuitBreaker.FailureRatio = 0.5; // Open circuit if 50% fail + options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30); + + // Individual attempt timeout + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30); + }); +``` + +**Respecting Retry-After Headers** + +The standard resilience handler automatically respects `Retry-After` headers that the Claude API may send during rate limiting: + +```csharp +services.AddHttpClient() + .AddStandardResilienceHandler() + .Configure(options => + { + // This is enabled by default + options.Retry.ShouldRetryAfterHeader = true; + }); +``` + +**Custom Resilience with AddResilienceHandler** + +For more granular control over individual resilience strategies: + +```csharp +services.AddHttpClient() + .AddResilienceHandler("anthropic-resilience", builder => + { + // Retry with exponential backoff for transient errors + builder.AddRetry(new HttpRetryStrategyOptions + { + MaxRetryAttempts = 5, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + Delay = TimeSpan.FromSeconds(1), + ShouldHandle = new PredicateBuilder() + .Handle() + .HandleResult(response => + (int)response.StatusCode == 429 || // Rate limit + (int)response.StatusCode >= 500 || // Server errors + (int)response.StatusCode == 408) // Request timeout + }); + + // Circuit breaker to prevent cascading failures + builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions + { + SamplingDuration = TimeSpan.FromSeconds(30), + FailureRatio = 0.2, + MinimumThroughput = 5, + BreakDuration = TimeSpan.FromSeconds(15) + }); + + // Timeout for individual attempts + builder.AddTimeout(TimeSpan.FromSeconds(30)); + + // Concurrency limiter to control outbound requests + builder.AddConcurrencyLimiter(100); + }) + .SetHandlerLifetime(TimeSpan.FromMinutes(5)); +``` + +**For GET-heavy Workloads: Hedging Handler** + +If you're primarily making idempotent requests (like retrieving model information), you can use hedging to reduce latency by sending parallel requests: + +```csharp +services.AddHttpClient() + .AddStandardHedgingHandler() + .Configure(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60); + + // Send another request if no response after 50ms + options.Hedging.MaxHedgedAttempts = 3; + options.Hedging.Delay = TimeSpan.FromMilliseconds(50); + + options.Endpoint.CircuitBreaker.MinimumThroughput = 5; + options.Endpoint.CircuitBreaker.FailureRatio = 0.9; + options.Endpoint.Timeout.Timeout = TimeSpan.FromSeconds(10); + }); +``` + +**⚠️ Warning**: Only use hedging for idempotent operations (GET requests). Do not use for message creation or other state-changing operations, as it sends multiple requests in parallel. + +### Custom Retry and Logging with IRequestInterceptor + +For applications that need custom retry logic, logging, or other cross-cutting concerns without using dependency injection, the SDK provides an `IRequestInterceptor` interface. + +**When to use IRequestInterceptor:** +- You need custom retry logic beyond what HttpClient provides +- You want to add request/response logging +- You need to track metrics or implement custom rate limiting +- You're not using dependency injection or HttpClientFactory + +**Note**: Most applications should use the HttpClient-level resilience patterns shown above. Only use `IRequestInterceptor` for advanced scenarios requiring per-request control. + +#### Retry Interceptor Example + +The SDK includes example implementations in the `Anthropic.SDK.Examples` namespace: + +```csharp +using Anthropic.SDK; +using Anthropic.SDK.Examples; + +// Create a retry interceptor with custom settings +var retryInterceptor = new RetryInterceptor( + maxRetries: 3, + initialDelay: TimeSpan.FromSeconds(1), + backoffMultiplier: 2.0 +); + +// Pass it to the AnthropicClient constructor +var client = new AnthropicClient( + apiKeys: new APIAuthentication("your-api-key"), + requestInterceptor: retryInterceptor +); + +// Use the client normally - retries happen automatically +var messages = new List +{ + new Message(RoleType.User, "Hello, Claude!") +}; + +var parameters = new MessageParameters +{ + Messages = messages, + MaxTokens = 1024, + Model = AnthropicModels.Claude35Sonnet +}; + +var response = await client.Messages.GetClaudeMessageAsync(parameters); +``` + +The `RetryInterceptor` will automatically retry on: +- HTTP 408 (Request Timeout) +- HTTP 429 (Too Many Requests) +- HTTP 500 (Internal Server Error) +- HTTP 502 (Bad Gateway) +- HTTP 503 (Service Unavailable) +- HTTP 504 (Gateway Timeout) +- Network-level failures (HttpRequestException, TimeoutException) + +#### Logging Interceptor Example + +You can also log requests and responses for debugging: + +```csharp +using Anthropic.SDK; +using Anthropic.SDK.Examples; + +// Create a logging interceptor +var loggingInterceptor = new LoggingInterceptor( + logRequestBody: true, // Log request bodies (be careful with sensitive data!) + logResponseBody: true // Log response bodies +); + +var client = new AnthropicClient( + apiKeys: new APIAuthentication("your-api-key"), + requestInterceptor: loggingInterceptor +); +``` + +**Note**: The logging interceptor only logs bodies under 10KB to avoid performance issues. Override the `LogRequest`, `LogResponse`, and `LogException` methods to customize logging behavior. + +#### Custom Interceptor Implementation + +You can create your own interceptor by implementing `IRequestInterceptor`: + +```csharp +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Anthropic.SDK; + +public class MyCustomInterceptor : IRequestInterceptor +{ + public async Task InvokeAsync( + HttpRequestMessage request, + Func> next, + CancellationToken cancellationToken = default) + { + // Custom logic before the request + Console.WriteLine($"Sending request to {request.RequestUri}"); + + // Execute the request + var response = await next(request, cancellationToken); + + // Custom logic after the request + Console.WriteLine($"Received response: {response.StatusCode}"); + + return response; + } +} + +// Use your custom interceptor +var client = new AnthropicClient( + apiKeys: new APIAuthentication("your-api-key"), + requestInterceptor: new MyCustomInterceptor() +); +``` + +**Important**: When implementing custom interceptors for retry logic, remember to: +1. Buffer request content before the first attempt (call `await request.Content.LoadIntoBufferAsync()`) +2. Clone the request for each retry attempt +3. Check `cancellationToken.IsCancellationRequested` to avoid retrying user-cancelled requests +4. Only retry transient failures (network errors, 5xx responses, 429) + +See the `RetryInterceptor` and `LoggingInterceptor` source code in `Anthropic.SDK/Examples/` for complete production-ready implementations. + ## Vertex AI Support Anthropic.SDK now supports accessing Claude models through Google Cloud's Vertex AI platform. This allows you to use Claude models with your existing Google Cloud infrastructure and authentication mechanisms.