From edd84329e34f7b7ff7fb58bdb81bf07aec967577 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:10:07 +0100 Subject: [PATCH 1/5] Dynamic clients --- src/Cloudflare.NET/CloudflareApiClient.cs | 27 ++ .../Core/CloudflareApiClientFactory.cs | 42 ++ .../Core/CloudflareHttpClientConfigurator.cs | 127 +++++ .../Core/DynamicCloudflareApiClient.cs | 146 ++++++ .../Core/ICloudflareApiClientFactory.cs | 50 ++ .../CloudflareResiliencePipelineBuilder.cs | 408 ++++++++++++++++ .../Resilience/ResilienceDelegatingHandler.cs | 73 +++ .../Core/ServiceCollectionExtensions.cs | 208 +-------- src/Cloudflare.NET/ICloudflareApiClient.cs | 2 +- .../UnitTests/DynamicClientTests.cs | 436 ++++++++++++++++++ 10 files changed, 1322 insertions(+), 197 deletions(-) create mode 100644 src/Cloudflare.NET/Core/CloudflareHttpClientConfigurator.cs create mode 100644 src/Cloudflare.NET/Core/DynamicCloudflareApiClient.cs create mode 100644 src/Cloudflare.NET/Core/Resilience/CloudflareResiliencePipelineBuilder.cs create mode 100644 src/Cloudflare.NET/Core/Resilience/ResilienceDelegatingHandler.cs create mode 100644 tests/Cloudflare.NET.Tests/UnitTests/DynamicClientTests.cs diff --git a/src/Cloudflare.NET/CloudflareApiClient.cs b/src/Cloudflare.NET/CloudflareApiClient.cs index f6043e3..d91f734 100644 --- a/src/Cloudflare.NET/CloudflareApiClient.cs +++ b/src/Cloudflare.NET/CloudflareApiClient.cs @@ -115,4 +115,31 @@ public CloudflareApiClient(HttpClient httpClient, IOptions public ITurnstileApi Turnstile => _turnstile.Value; #endregion + + + #region Methods Impl - IDisposable + + /// + /// Disposes this client instance. + /// + /// + /// + /// For DI-managed clients, this method does nothing because the + /// lifetime is managed by . The factory handles pooling + /// and disposal of the underlying handlers automatically. + /// + /// + /// For dynamic clients created via + /// , + /// the wrapper handles actual disposal of resources. + /// + /// + public void Dispose() + { + // No-op for DI-managed clients. The HttpClient is managed by IHttpClientFactory + // and should not be disposed by the client. For dynamic clients, the + // DynamicCloudflareApiClient wrapper handles disposal. + } + + #endregion } diff --git a/src/Cloudflare.NET/Core/CloudflareApiClientFactory.cs b/src/Cloudflare.NET/Core/CloudflareApiClientFactory.cs index b1dc3f6..bf7a598 100644 --- a/src/Cloudflare.NET/Core/CloudflareApiClientFactory.cs +++ b/src/Cloudflare.NET/Core/CloudflareApiClientFactory.cs @@ -3,6 +3,7 @@ namespace Cloudflare.NET.Core; using Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Resilience; using Validation; /// @@ -79,6 +80,47 @@ public ICloudflareApiClient CreateClient(string name) return new CloudflareApiClient(httpClient, optionsWrapper, _loggerFactory); } + + /// + public ICloudflareApiClient CreateClient(CloudflareApiOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + // Validate the options using the shared validator for consistent, clear error messages. + ValidateNamedClientConfiguration("dynamic", options); + + // Build the resilience pipeline using the shared builder. + // This ensures the same resilience behavior as DI-registered clients. + var logger = _loggerFactory.CreateLogger(LoggingConstants.Categories.HttpResilience); + var pipeline = CloudflareResiliencePipelineBuilder.Build(options, logger, clientName: "dynamic"); + + // Create the handler chain: ResilienceHandler → SocketsHttpHandler. + // SocketsHttpHandler is used for proper connection pooling and lifetime management. + var socketHandler = new SocketsHttpHandler + { + // PooledConnectionLifetime ensures connections are recycled periodically, + // which helps with DNS changes and prevents stale connections. + // This mirrors the behavior of IHttpClientFactory-managed handlers. + PooledConnectionLifetime = TimeSpan.FromMinutes(2) + }; + + var resilienceHandler = new ResilienceDelegatingHandler(pipeline, socketHandler); + + // Create and configure the HttpClient using the shared configurator. + // This ensures the same configuration as DI-registered clients. + var httpClient = new HttpClient(resilienceHandler); + CloudflareHttpClientConfigurator.Configure(httpClient, options, setAuthorizationHeader: true); + + // Wrap the options in IOptions for the CloudflareApiClient constructor. + var optionsWrapper = new NamedOptionsWrapper(options); + + // Create the inner client that handles all API operations. + var innerClient = new CloudflareApiClient(httpClient, optionsWrapper, _loggerFactory); + + // Wrap in DynamicCloudflareApiClient which handles disposal of the owned HttpClient. + return new DynamicCloudflareApiClient(innerClient, httpClient); + } + #endregion #region Methods diff --git a/src/Cloudflare.NET/Core/CloudflareHttpClientConfigurator.cs b/src/Cloudflare.NET/Core/CloudflareHttpClientConfigurator.cs new file mode 100644 index 0000000..480c4a0 --- /dev/null +++ b/src/Cloudflare.NET/Core/CloudflareHttpClientConfigurator.cs @@ -0,0 +1,127 @@ +namespace Cloudflare.NET.Core; + +using System.Net.Http.Headers; + +/// +/// Configures instances for Cloudflare API communication. +/// Used by both DI registration and dynamic client creation paths to ensure +/// consistent HTTP client configuration. +/// +/// +/// +/// This class centralizes the HttpClient configuration logic that was previously +/// duplicated between the DI registration path and the dynamic client creation path. +/// +/// +/// Configuration includes: +/// +/// +/// +/// +/// Base Address - Set from . +/// +/// +/// +/// +/// Timeout - Set to a long value (5 minutes) to allow the resilience pipeline +/// to handle timeouts. The actual timeout is controlled by the pipeline. +/// +/// +/// +/// +/// Authorization Header - Optionally set from . +/// +/// +/// +/// +public static class CloudflareHttpClientConfigurator +{ + #region Constants + + /// + /// The HttpClient timeout. This is intentionally long to allow the resilience + /// pipeline's timeout strategies to be the effective timeout controllers. + /// + /// + /// Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#httpclient-timeout + /// + private static readonly TimeSpan HttpClientTimeout = TimeSpan.FromMinutes(5); + + #endregion + + + #region Methods - Public + + /// + /// Configures an for Cloudflare API communication. + /// + /// The HttpClient to configure. + /// The Cloudflare API options containing configuration values. + /// + /// If true, sets the Authorization header from the options. Set to false when + /// authentication is handled separately (e.g., via ). + /// + /// Thrown when or is null. + /// Thrown when is null or whitespace. + /// + /// + /// The HttpClient timeout is set to a long value (5 minutes) to ensure that the resilience + /// pipeline's timeout strategies are the effective timeout controllers. Without this, the + /// HttpClient's default 100-second timeout would interfere with retry attempts. + /// + /// + /// + /// + /// // For DI registration path (auth handled by AuthenticationHandler) + /// CloudflareHttpClientConfigurator.Configure(httpClient, options, setAuthorizationHeader: false); + /// + /// // For named clients or dynamic clients (auth header set directly) + /// CloudflareHttpClientConfigurator.Configure(httpClient, options, setAuthorizationHeader: true); + /// + /// + public static void Configure(HttpClient client, + CloudflareApiOptions options, + bool setAuthorizationHeader = true) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(options); + + // Validate the API base URL. + if (string.IsNullOrWhiteSpace(options.ApiBaseUrl)) + throw new InvalidOperationException( + "Cloudflare API Base URL is missing. Please configure it in the 'Cloudflare' settings section."); + + // Set the base address for all requests. + client.BaseAddress = new Uri(options.ApiBaseUrl); + + // Set a long HttpClient.Timeout so that our resilience pipeline's TotalRequestTimeout is the effective timeout. + // Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#httpclient-timeout + client.Timeout = HttpClientTimeout; + + // Optionally set the Authorization header. + if (setAuthorizationHeader && !string.IsNullOrWhiteSpace(options.ApiToken)) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken); + } + + + /// + /// Configures only the base properties of an (base address and timeout), + /// without setting the Authorization header. + /// + /// The HttpClient to configure. + /// The Cloudflare API options containing configuration values. + /// Thrown when or is null. + /// Thrown when is null or whitespace. + /// + /// + /// This overload is provided for backward compatibility and convenience when authentication + /// is handled by a separate . + /// + /// + public static void ConfigureBase(HttpClient client, CloudflareApiOptions options) + { + Configure(client, options, setAuthorizationHeader: false); + } + + #endregion +} diff --git a/src/Cloudflare.NET/Core/DynamicCloudflareApiClient.cs b/src/Cloudflare.NET/Core/DynamicCloudflareApiClient.cs new file mode 100644 index 0000000..d637996 --- /dev/null +++ b/src/Cloudflare.NET/Core/DynamicCloudflareApiClient.cs @@ -0,0 +1,146 @@ +namespace Cloudflare.NET.Core; + +using Accounts; +using ApiTokens; +using AuditLogs; +using Dns; +using Members; +using Roles; +using Subscriptions; +using Turnstile; +using User; +using Workers; +using Zones; + +/// +/// A Cloudflare API client created dynamically (at runtime) that owns its +/// and disposes it when disposed. +/// +/// +/// +/// Unlike DI-managed clients where the lifetime is managed by +/// , dynamic clients own their +/// and must dispose it to release the underlying +/// and connections. +/// +/// +/// This class wraps a standard and adds disposal semantics. +/// All API operations are delegated to the inner client. +/// +/// +/// Users should dispose this client when it is no longer needed: +/// +/// +/// using var client = factory.CreateClient(options); +/// // Use the client... +/// +/// +internal sealed class DynamicCloudflareApiClient : ICloudflareApiClient +{ + #region Properties & Fields - Non-Public + + /// The inner Cloudflare API client that handles all API operations. + private readonly CloudflareApiClient _innerClient; + + /// The HttpClient that this instance owns and will dispose. + private readonly HttpClient _ownedHttpClient; + + /// Indicates whether this instance has been disposed. + private bool _disposed; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The inner Cloudflare API client. + /// The HttpClient that this instance owns and will dispose. + internal DynamicCloudflareApiClient(CloudflareApiClient innerClient, HttpClient ownedHttpClient) + { + _innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient)); + _ownedHttpClient = ownedHttpClient ?? throw new ArgumentNullException(nameof(ownedHttpClient)); + } + + #endregion + + + #region Properties Impl - ICloudflareApiClient + + /// + public IAccountsApi Accounts => ThrowIfDisposed()._innerClient.Accounts; + + /// + public IUserApi User => ThrowIfDisposed()._innerClient.User; + + /// + public IZonesApi Zones => ThrowIfDisposed()._innerClient.Zones; + + /// + public IDnsApi Dns => ThrowIfDisposed()._innerClient.Dns; + + /// + public IAuditLogsApi AuditLogs => ThrowIfDisposed()._innerClient.AuditLogs; + + /// + public IApiTokensApi ApiTokens => ThrowIfDisposed()._innerClient.ApiTokens; + + /// + public IRolesApi Roles => ThrowIfDisposed()._innerClient.Roles; + + /// + public IMembersApi Members => ThrowIfDisposed()._innerClient.Members; + + /// + public ISubscriptionsApi Subscriptions => ThrowIfDisposed()._innerClient.Subscriptions; + + /// + public IWorkersApi Workers => ThrowIfDisposed()._innerClient.Workers; + + /// + public ITurnstileApi Turnstile => ThrowIfDisposed()._innerClient.Turnstile; + + #endregion + + + #region Methods Impl - IDisposable + + /// + /// Releases the resources used by this client, including the owned . + /// + /// + /// + /// After disposal, any attempt to access the API properties will throw + /// . + /// + /// + public void Dispose() + { + if (_disposed) + return; + + _ownedHttpClient.Dispose(); + _disposed = true; + } + + #endregion + + + #region Methods - Private + + /// + /// Throws if this instance has been disposed. + /// + /// This instance, for fluent chaining. + /// Thrown if this instance has been disposed. + private DynamicCloudflareApiClient ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + return this; + } + + #endregion +} diff --git a/src/Cloudflare.NET/Core/ICloudflareApiClientFactory.cs b/src/Cloudflare.NET/Core/ICloudflareApiClientFactory.cs index d27bb79..4d8d35d 100644 --- a/src/Cloudflare.NET/Core/ICloudflareApiClientFactory.cs +++ b/src/Cloudflare.NET/Core/ICloudflareApiClientFactory.cs @@ -46,5 +46,55 @@ public interface ICloudflareApiClientFactory /// Thrown when no client with the specified name has been registered. ICloudflareApiClient CreateClient(string name); + + /// + /// Creates an instance dynamically from the provided options, + /// without requiring pre-registration in the DI container. + /// + /// The configuration options for the client. + /// + /// A fully configured with authentication and resilience + /// (rate limiting, retries, circuit breaker, timeouts). + /// + /// Thrown when is null. + /// Thrown when the options fail validation (e.g., missing ApiToken). + /// + /// + /// Use this method when client configurations are not known at application startup, + /// such as when users can add Cloudflare accounts at runtime through a UI. + /// + /// + /// The returned client manages its own instance and should be disposed + /// when no longer needed to release resources. Use a using statement or call : + /// + /// + /// using var client = factory.CreateClient(options); + /// var zones = await client.Zones.ListZonesAsync(); + /// // Client is disposed when the using scope ends + /// + /// + /// Each dynamic client has its own isolated resilience pipeline (rate limiter, circuit breaker, etc.). + /// Dynamic clients do not share state with pre-registered named clients or other dynamic clients. + /// + /// + /// + /// + /// // Create a dynamic client for a user-provided account + /// var options = new CloudflareApiOptions + /// { + /// ApiToken = userProvidedToken, + /// AccountId = userProvidedAccountId, + /// RateLimiting = new RateLimitingOptions + /// { + /// IsEnabled = true, + /// PermitLimit = 10 // Conservative limit for user accounts + /// } + /// }; + /// + /// var client = factory.CreateClient(options); + /// + /// + ICloudflareApiClient CreateClient(CloudflareApiOptions options); + #endregion } diff --git a/src/Cloudflare.NET/Core/Resilience/CloudflareResiliencePipelineBuilder.cs b/src/Cloudflare.NET/Core/Resilience/CloudflareResiliencePipelineBuilder.cs new file mode 100644 index 0000000..12ef69c --- /dev/null +++ b/src/Cloudflare.NET/Core/Resilience/CloudflareResiliencePipelineBuilder.cs @@ -0,0 +1,408 @@ +namespace Cloudflare.NET.Core.Resilience; + +using System.Net; +using System.Threading.RateLimiting; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Retry; +using Polly.Timeout; +using RateLimitHeaders.Polly; + +/// +/// Builds and configures resilience pipelines for Cloudflare API clients. +/// Used by both DI registration and dynamic client creation paths to ensure +/// consistent resilience behavior. +/// +/// +/// +/// The resilience pipeline includes the following strategies (outermost to innermost): +/// +/// +/// +/// +/// Rate Limiter - Client-side concurrency limiting to prevent overwhelming the API. +/// +/// +/// +/// +/// Rate Limit Headers - Proactive throttling based on server-returned rate limit headers. +/// +/// +/// +/// +/// Total Request Timeout - Overall timeout (60 seconds) covering all retries. +/// +/// +/// +/// +/// Retry Strategy - Exponential backoff with jitter for transient failures +/// (only for idempotent HTTP methods). +/// +/// +/// +/// +/// Circuit Breaker - Stops sending requests after consecutive failures. +/// +/// +/// +/// +/// Attempt Timeout - Per-request timeout from . +/// +/// +/// +/// +public static class CloudflareResiliencePipelineBuilder +{ + #region Constants + + /// + /// The total timeout for all retries combined. This is the outermost timeout + /// that limits how long the entire operation (including retries) can take. + /// + private static readonly TimeSpan TotalRequestTimeout = TimeSpan.FromSeconds(60); + + /// The initial delay between retry attempts before jitter is applied. + private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(1); + + #endregion + + + #region Methods - Public + + /// + /// Configures an existing resilience pipeline builder with Cloudflare-specific strategies + /// (rate limiting, retries, circuit breaker, timeouts). + /// + /// The resilience pipeline builder to configure. + /// The Cloudflare API options containing resilience configuration. + /// Optional logger for resilience events. If null, events are not logged. + /// Optional client name for logging context and strategy naming. + /// + /// + /// This method is used by the DI registration path via AddResilienceHandler(). + /// The same configuration logic is used by for dynamic clients. + /// + /// + public static void Configure(ResiliencePipelineBuilder builder, + CloudflareApiOptions options, + ILogger? logger = null, + string? clientName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + // Create name prefix for this specific client's resilience components. + var namePrefix = clientName is null ? "Cloudflare" : $"Cloudflare:{clientName}"; + + // The standard pipeline order is: + // Rate Limiter -> Rate Limit Headers -> Total Timeout -> Retry -> Circuit Breaker -> Attempt Timeout. + // Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#standard-pipeline + + ConfigureRateLimiter(builder, options, logger, namePrefix); + ConfigureRateLimitHeaders(builder, options); + ConfigureTotalTimeout(builder, namePrefix); + ConfigureRetry(builder, options, logger, namePrefix); + ConfigureCircuitBreaker(builder, namePrefix); + ConfigureAttemptTimeout(builder, options, namePrefix); + } + + + /// + /// Builds a standalone resilience pipeline configured for Cloudflare API calls. + /// + /// The Cloudflare API options containing resilience configuration. + /// Optional logger for resilience events. If null, events are not logged. + /// Optional client name for logging context and strategy naming. + /// + /// A configured ready for use + /// with an . + /// + /// + /// + /// This method is used by the dynamic client creation path to build a resilience pipeline + /// without requiring DI registration. The pipeline can be used with a + /// to wrap an . + /// + /// + /// + /// + /// var pipeline = CloudflareResiliencePipelineBuilder.Build(options, logger); + /// var handler = new ResilienceDelegatingHandler(pipeline, new SocketsHttpHandler()); + /// var httpClient = new HttpClient(handler); + /// + /// + public static ResiliencePipeline Build(CloudflareApiOptions options, + ILogger? logger = null, + string? clientName = null) + { + ArgumentNullException.ThrowIfNull(options); + + var builder = new ResiliencePipelineBuilder(); + + Configure(builder, options, logger, clientName); + + return builder.Build(); + } + + #endregion + + + #region Methods - Private + + /// + /// Configures the client-side rate limiter strategy. + /// + /// + /// + /// This is the OUTERMOST strategy. It limits the number of concurrent requests + /// to prevent overwhelming the Cloudflare API. + /// + /// + private static void ConfigureRateLimiter(ResiliencePipelineBuilder builder, + CloudflareApiOptions options, + ILogger? logger, + string namePrefix) + { + var rateLimiterName = $"{namePrefix}:RateLimiter"; + + builder.AddRateLimiter(new HttpRateLimiterStrategyOptions + { + Name = rateLimiterName, + DefaultRateLimiterOptions = new ConcurrencyLimiterOptions + { + PermitLimit = Math.Max(1, options.RateLimiting.PermitLimit), + QueueLimit = Math.Max(0, options.RateLimiting.QueueLimit), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }, + OnRejected = args => + { + if (logger is null) + return default; + + // Try to extract a Retry-After hint if the limiter can calculate one. + args.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter); + + logger.LogWarning( + "Request rejected by {Strategy}. Concurrency limit reached. RetryAfter={RetryAfter}.", + rateLimiterName, retryAfter == default ? "n/a" : $"{(int)retryAfter.TotalSeconds}s"); + + return default; + } + }); + } + + + /// + /// Configures proactive throttling based on server-returned rate limit headers. + /// + /// + /// + /// This processes headers like RateLimit-Remaining, RateLimit-Limit, and RateLimit-Reset + /// to preemptively slow down requests before hitting 429 responses. + /// + /// + private static void ConfigureRateLimitHeaders(ResiliencePipelineBuilder builder, + CloudflareApiOptions options) + { + // Ref: https://alos.no/ratelimitheaders/articles/polly-integration.html + builder.AddRateLimitHeaders(rateLimitOptions => + { + // Enable proactive throttling to delay requests when quota is running low. + rateLimitOptions.EnableProactiveThrottling = options.RateLimiting.EnableProactiveThrottling; + + // Set the threshold at which throttling begins (e.g., 0.1 = 10% remaining). + rateLimitOptions.QuotaLowThreshold = options.RateLimiting.QuotaLowThreshold; + }); + } + + + /// + /// Configures the total request timeout covering all retries. + /// + /// + /// + /// This is an outer timeout that limits how long the entire operation (including all retry + /// attempts) can take. It prevents unbounded retry loops. + /// + /// + private static void ConfigureTotalTimeout(ResiliencePipelineBuilder builder, + string namePrefix) + { + builder.AddTimeout(new HttpTimeoutStrategyOptions + { + Name = $"{namePrefix}:TotalTimeout", + Timeout = TotalRequestTimeout + }); + } + + + /// + /// Configures the retry strategy for transient failures. + /// + /// + /// + /// Only idempotent HTTP methods (GET, HEAD, OPTIONS, TRACE, PUT, DELETE) are retried. + /// Non-idempotent methods (POST, PATCH) are not retried to prevent duplicate side effects. + /// + /// + /// Retries are performed with exponential backoff and jitter. The strategy honors + /// the Retry-After header when present. + /// + /// + private static void ConfigureRetry(ResiliencePipelineBuilder builder, + CloudflareApiOptions options, + ILogger? logger, + string namePrefix) + { + // Polly v8 validates MaxRetryAttempts >= 1. If users set MaxRetries = 0 to disable retries, + // we skip adding the retry component entirely to avoid validation failures. + if (options.RateLimiting.MaxRetries <= 0) + return; + + var retryOptions = new HttpRetryStrategyOptions + { + // Defaults handle 5xx, 408, 429, HttpRequestException, and TimeoutRejectedException. + // Ref: https://learn.microsoft.com/en-us/dotnet/api/polly.extensions.http.httpretrystrategyoptions + Name = $"{namePrefix}:Retry", + BackoffType = DelayBackoffType.Exponential, + Delay = RetryBaseDelay, + UseJitter = true, + MaxRetryAttempts = options.RateLimiting.MaxRetries, + + // By default, HttpRetryStrategyOptions honors the 'Retry-After' header. A custom generator is not needed. + // Ref: https://devblogs.microsoft.com/dotnet/building-resilient-cloud-services-with-dotnet-8/ + + // This custom predicate ensures we always retry on server errors (5xx) and transient exceptions, + // but only retry on 429s if rate limit handling is explicitly enabled in options. + ShouldHandle = args => ShouldRetry(args, options), + OnRetry = args => OnRetry(args, options, logger) + }; + + // IMPORTANT: keep DisableForUnsafeHttpMethods removed so DELETE/PUT can retry when appropriate. + // retryOptions.DisableForUnsafeHttpMethods(); + + builder.AddRetry(retryOptions); + } + + + /// + /// Determines whether a failed request should be retried based on the outcome and options. + /// + /// The retry predicate arguments containing the outcome. + /// The Cloudflare API options. + /// True if the request should be retried; otherwise, false. + private static ValueTask ShouldRetry(RetryPredicateArguments args, + CloudflareApiOptions options) + { + // ----- Method-based gating (idempotency) ----- + // Do NOT retry non-idempotent methods. + // Retry allowed for: GET, HEAD, OPTIONS, TRACE, PUT, DELETE. + // No retry for: POST, PATCH, CONNECT (and anything else not listed above). + + // 0) Gate by HTTP method *idempotency*, not "safety" + var method = args.Outcome.Result?.RequestMessage?.Method; + + // Treat GET, HEAD, OPTIONS, TRACE, PUT, DELETE as idempotent. + if (method is not null) + { + var isIdempotent = + method == HttpMethod.Get || + method == HttpMethod.Head || + method == HttpMethod.Options || + method == HttpMethod.Trace || + method == HttpMethod.Put || + method == HttpMethod.Delete; + + if (!isIdempotent) + return new ValueTask(false); + } + + // 1) Exceptions we consider transient. + if (args.Outcome.Exception is HttpRequestException or TimeoutRejectedException) + return new ValueTask(true); + + // 2) HTTP responses we consider transient. + if (args.Outcome.Result is not { } response) + return new ValueTask(false); + + var statusCode = response.StatusCode; + + // Retry on 408 and 5xx. + if (statusCode == HttpStatusCode.RequestTimeout || (int)statusCode >= 500) + return new ValueTask(true); + + // Retry on 429 only when rate-limit handling is enabled. + if (statusCode == HttpStatusCode.TooManyRequests) + return new ValueTask(options.RateLimiting.IsEnabled); + + return new ValueTask(false); + } + + + /// + /// Handles the retry event by logging the retry attempt. + /// + private static ValueTask OnRetry(OnRetryArguments args, + CloudflareApiOptions options, + ILogger? logger) + { + if (logger is null) + return default; + + var req = args.Outcome.Result?.RequestMessage; + + logger.LogWarning( + "Transient failure for {Method} {Uri}. Attempt {Attempt}/{MaxAttempts}. Next delay: {Delay}.", + req?.Method, + req?.RequestUri, + args.AttemptNumber + 1, + options.RateLimiting.MaxRetries, + args.RetryDelay); + + return default; + } + + + /// + /// Configures the circuit breaker strategy. + /// + /// + /// + /// The circuit breaker stops sending requests after too many consecutive failures, + /// allowing the downstream service time to recover. + /// + /// + private static void ConfigureCircuitBreaker(ResiliencePipelineBuilder builder, + string namePrefix) + { + builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions + { + Name = $"{namePrefix}:CircuitBreaker" + }); + } + + + /// + /// Configures the per-attempt timeout. + /// + /// + /// + /// This is the INNERMOST strategy. It limits how long each individual attempt + /// (before any retry) can take. The timeout value comes from + /// . + /// + /// + private static void ConfigureAttemptTimeout(ResiliencePipelineBuilder builder, + CloudflareApiOptions options, + string namePrefix) + { + builder.AddTimeout(new HttpTimeoutStrategyOptions + { + Name = $"{namePrefix}:AttemptTimeout", + Timeout = options.DefaultTimeout + }); + } + + #endregion +} diff --git a/src/Cloudflare.NET/Core/Resilience/ResilienceDelegatingHandler.cs b/src/Cloudflare.NET/Core/Resilience/ResilienceDelegatingHandler.cs new file mode 100644 index 0000000..b774812 --- /dev/null +++ b/src/Cloudflare.NET/Core/Resilience/ResilienceDelegatingHandler.cs @@ -0,0 +1,73 @@ +namespace Cloudflare.NET.Core.Resilience; + +using Polly; + +/// +/// A that wraps HTTP requests with a resilience pipeline. +/// +/// +/// +/// This handler is used for dynamic client creation where we need to apply a resilience +/// pipeline without using the DI-based AddResilienceHandler extension method. +/// +/// +/// Note: This is a simplified implementation. The official ResilienceHandler from +/// Microsoft.Extensions.Http.Resilience became public in later versions (9.x+). +/// This class provides equivalent functionality for version 8.x compatibility. +/// +/// +internal sealed class ResilienceDelegatingHandler : DelegatingHandler +{ + #region Properties & Fields - Non-Public + + /// The resilience pipeline to apply to HTTP requests. + private readonly ResiliencePipeline _pipeline; + + #endregion + + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + /// The resilience pipeline to apply to HTTP requests. + /// Thrown when is null. + public ResilienceDelegatingHandler(ResiliencePipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + + /// + /// Initializes a new instance of the class + /// with the specified inner handler. + /// + /// The resilience pipeline to apply to HTTP requests. + /// The inner handler to delegate requests to. + /// + /// Thrown when or is null. + /// + public ResilienceDelegatingHandler(ResiliencePipeline pipeline, HttpMessageHandler innerHandler) + : base(innerHandler) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + #endregion + + + #region Methods - Overrides + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Execute the request through the resilience pipeline. + // The pipeline will handle retries, timeouts, circuit breaker, etc. + return await _pipeline.ExecuteAsync( + async token => await base.SendAsync(request, token).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + } + + #endregion +} diff --git a/src/Cloudflare.NET/Core/ServiceCollectionExtensions.cs b/src/Cloudflare.NET/Core/ServiceCollectionExtensions.cs index 66ca7d4..0565d44 100644 --- a/src/Cloudflare.NET/Core/ServiceCollectionExtensions.cs +++ b/src/Cloudflare.NET/Core/ServiceCollectionExtensions.cs @@ -1,19 +1,14 @@ namespace Cloudflare.NET.Core; -using System.Net; using System.Net.Http.Headers; -using System.Threading.RateLimiting; using Auth; using Internal; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Polly; -using Polly.Timeout; -using RateLimitHeaders.Polly; +using Resilience; using Validation; /// Provides extension methods for setting up the Cloudflare API client in an IServiceCollection. @@ -98,12 +93,14 @@ public static IServiceCollection AddCloudflareApiClient(this IServiceCollection // Resolve the configured options. var options = serviceProvider.GetRequiredService>().Value; - ConfigureHttpClient(client, options); + // Configure base properties (base address, timeout). + // Auth header is handled by AuthenticationHandler, so don't set it here. + CloudflareHttpClientConfigurator.ConfigureBase(client, options); }) .AddHttpMessageHandler(); // Add the resilience handler for the default client. - AddResilienceHandler(builder, true, null); + AddResilienceHandler(builder, resolveOptionsFromDi: true, clientName: null); // Register the factory as a singleton. It will be shared by all named client registrations. services.TryAddSingleton(); @@ -221,15 +218,12 @@ public static IServiceCollection AddCloudflareApiClient(this IServiceCollection var optionsMonitor = serviceProvider.GetRequiredService>(); var options = optionsMonitor.Get(name); - ConfigureHttpClient(client, options); - - // Set the Authorization header directly for named clients. - if (!string.IsNullOrWhiteSpace(options.ApiToken)) - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken); + // Configure all properties including the Authorization header for named clients. + CloudflareHttpClientConfigurator.Configure(client, options, setAuthorizationHeader: true); }); // Add the resilience handler for the named client. - AddResilienceHandler(builder, false, name); + AddResilienceHandler(builder, resolveOptionsFromDi: false, clientName: name); // Register the factory as a singleton. It will be shared by all named client registrations. // TryAdd ensures we don't replace an existing registration. @@ -257,24 +251,6 @@ internal static string GetHttpClientName(string clientName) return $"{HttpClientNamePrefix}:{clientName}"; } - /// Configures the base properties of an HttpClient for Cloudflare API access. - /// The HttpClient to configure. - /// The options containing the API configuration. - private static void ConfigureHttpClient(HttpClient client, CloudflareApiOptions options) - { - // Use the URL from the options, which has a built-in default value. - if (string.IsNullOrWhiteSpace(options.ApiBaseUrl)) - throw new InvalidOperationException( - "Cloudflare API Base URL is missing. Please configure it in the 'Cloudflare' settings section."); - - client.BaseAddress = new Uri(options.ApiBaseUrl); - - // Set a long HttpClient.Timeout so that our resilience pipeline's TotalRequestTimeout is the effective timeout. - // Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#httpclient-timeout - client.Timeout = TimeSpan.FromMinutes(5); - } - - /// Adds the resilience handler to an HttpClient builder. /// The HttpClient builder. /// @@ -296,7 +272,7 @@ private static void AddResilienceHandler(IHttpClientBuilder builder, bool resolv // rate limiter tuned for API access. We use a single handler to avoid the complexities and // potential for compounding delays that come from stacking multiple handlers. // Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#avoid-stacking-handlers - builder.AddResilienceHandler(pipelineName, (b, context) => + builder.AddResilienceHandler(pipelineName, (pipelineBuilder, context) => { // Resolve options based on whether this is the default or a named client. CloudflareApiOptions cfOptions; @@ -314,169 +290,9 @@ private static void AddResilienceHandler(IHttpClientBuilder builder, bool resolv var logger = context.ServiceProvider.GetRequiredService() .CreateLogger(LoggingConstants.Categories.HttpResilience); - ConfigureResiliencePipeline(b, cfOptions, logger, clientName); - }); - } - - - /// Configures the resilience pipeline with rate limiting, retries, and circuit breaker. - /// The resilience pipeline builder. - /// The Cloudflare API options. - /// The logger for resilience events. - /// Optional client name for logging context. - private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder builder, - CloudflareApiOptions cfOptions, - ILogger logger, - string? clientName) - { - // Create name prefixes for this specific client's resilience components. - var namePrefix = clientName is null ? "Cloudflare" : $"Cloudflare:{clientName}"; - - // The standard pipeline order is: - // Rate Limiter -> Rate Limit Headers -> Total Timeout -> Retry -> Circuit Breaker -> Attempt Timeout. - // Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#standard-pipeline - - // 1. OUTERMOST: Client-side rate limiting to avoid sending requests that are likely to be throttled. - var rateLimiterName = $"{namePrefix}:RateLimiter"; - - builder.AddRateLimiter(new HttpRateLimiterStrategyOptions - { - Name = rateLimiterName, - DefaultRateLimiterOptions = new ConcurrencyLimiterOptions - { - PermitLimit = Math.Max(1, cfOptions.RateLimiting.PermitLimit), - QueueLimit = Math.Max(0, cfOptions.RateLimiting.QueueLimit), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst - }, - OnRejected = args => - { - // Try to extract a Retry-After hint if the limiter can calculate one. - args.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter); - - logger.LogWarning( - "Request rejected by {Strategy}. Concurrency limit reached. RetryAfter={RetryAfter}.", - rateLimiterName, retryAfter == default ? "n/a" : $"{(int)retryAfter.TotalSeconds}s"); - - return default; - } - }); - - // 2. Rate Limit Headers: Proactive throttling based on server-returned rate limit headers. - // This processes headers like RateLimit-Remaining, RateLimit-Limit, and RateLimit-Reset to - // preemptively slow down requests before hitting 429 responses. - // Ref: https://alos.no/ratelimitheaders/articles/polly-integration.html - builder.AddRateLimitHeaders(options => - { - // Enable proactive throttling to delay requests when quota is running low. - options.EnableProactiveThrottling = cfOptions.RateLimiting.EnableProactiveThrottling; - - // Set the threshold at which throttling begins (e.g., 0.1 = 10% remaining). - options.QuotaLowThreshold = cfOptions.RateLimiting.QuotaLowThreshold; - }); - - // 3. Total Request Timeout: An outer timeout that covers the entire operation, including all retries. - builder.AddTimeout(new HttpTimeoutStrategyOptions - { - Name = $"{namePrefix}:TotalTimeout", - Timeout = TimeSpan.FromSeconds(60) - }); - - // 4. Retry Strategy: Handles transient failures. Only add when enabled. - // Polly v8 validates MaxRetryAttempts >= 1. If users set MaxRetries = 0 to disable retries, - // we skip adding the retry component entirely to avoid validation failures. - if (cfOptions.RateLimiting.MaxRetries > 0) - { - var retryOptions = new HttpRetryStrategyOptions - { - // Defaults handle 5xx, 408, 429, HttpRequestException, and TimeoutRejectedException. - // Ref: https://learn.microsoft.com/en-us/dotnet/api/polly.extensions.http.httpretrystrategyoptions - Name = $"{namePrefix}:Retry", - BackoffType = DelayBackoffType.Exponential, - Delay = TimeSpan.FromSeconds(1), - UseJitter = true, - MaxRetryAttempts = cfOptions.RateLimiting.MaxRetries, - - // By default, HttpRetryStrategyOptions honors the 'Retry-After' header. A custom generator is not needed. - // Ref: https://devblogs.microsoft.com/dotnet/building-resilient-cloud-services-with-dotnet-8/ - - // This custom predicate ensures we always retry on server errors (5xx) and transient exceptions, - // but only retry on 429s if rate limit handling is explicitly enabled in options. - ShouldHandle = args => - { - // ----- Method-based gating (idempotency) ----- - // Do NOT retry non-idempotent methods. - // Retry allowed for: GET, HEAD, OPTIONS, TRACE, PUT, DELETE. - // No retry for: POST, PATCH, CONNECT (and anything else not listed above). - - // 0) Gate by HTTP method *idempotency*, not "safety" - var method = args.Outcome.Result?.RequestMessage?.Method; - - // Treat GET, HEAD, OPTIONS, TRACE, PUT, DELETE as idempotent. - if (method is not null) - { - var isIdempotent = - method == HttpMethod.Get || - method == HttpMethod.Head || - method == HttpMethod.Options || - method == HttpMethod.Trace || - method == HttpMethod.Put || - method == HttpMethod.Delete; - - if (!isIdempotent) - return new ValueTask(false); - } - - // 1) Exceptions we consider transient. - if (args.Outcome.Exception is HttpRequestException or TimeoutRejectedException) - return new ValueTask(true); - - // 2) HTTP responses we consider transient. - if (args.Outcome.Result is not { } response) - return new ValueTask(false); - - var statusCode = response.StatusCode; - - // Retry on 408 and 5xx. - if (statusCode == HttpStatusCode.RequestTimeout || (int)statusCode >= 500) - return new ValueTask(true); - - // Retry on 429 only when rate-limit handling is enabled. - if (statusCode == HttpStatusCode.TooManyRequests) - return new ValueTask(cfOptions.RateLimiting.IsEnabled); - - return new ValueTask(false); - }, - - OnRetry = args => - { - var req = args.Outcome.Result?.RequestMessage; - - logger.LogWarning( - "Transient failure for {Method} {Uri}. Attempt {Attempt}/{MaxAttempts}. Next delay: {Delay}.", - req?.Method, - req?.RequestUri, - args.AttemptNumber + 1, - cfOptions.RateLimiting.MaxRetries, - args.RetryDelay); - - return default; - } - }; - - // IMPORTANT: keep this removed so DELETE/PUT can retry when appropriate. - // retryOptions.DisableForUnsafeHttpMethods(); - - builder.AddRetry(retryOptions); - } - - // 5. Circuit Breaker: Stops sending requests after too many consecutive failures. Defaults are sensible. - builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { Name = $"{namePrefix}:CircuitBreaker" }); - - // 6. INNERMOST: Per-attempt timeout, using our configurable value. - builder.AddTimeout(new HttpTimeoutStrategyOptions - { - Name = $"{namePrefix}:AttemptTimeout", - Timeout = cfOptions.DefaultTimeout + // Use the shared resilience pipeline builder to ensure consistent configuration + // between DI-registered clients and dynamically created clients. + CloudflareResiliencePipelineBuilder.Configure(pipelineBuilder, cfOptions, logger, clientName); }); } diff --git a/src/Cloudflare.NET/ICloudflareApiClient.cs b/src/Cloudflare.NET/ICloudflareApiClient.cs index c3e96d8..2d2ff6c 100644 --- a/src/Cloudflare.NET/ICloudflareApiClient.cs +++ b/src/Cloudflare.NET/ICloudflareApiClient.cs @@ -36,7 +36,7 @@ /// /// /// -public interface ICloudflareApiClient +public interface ICloudflareApiClient : IDisposable { #region Properties & Fields - Public diff --git a/tests/Cloudflare.NET.Tests/UnitTests/DynamicClientTests.cs b/tests/Cloudflare.NET.Tests/UnitTests/DynamicClientTests.cs new file mode 100644 index 0000000..22595ea --- /dev/null +++ b/tests/Cloudflare.NET.Tests/UnitTests/DynamicClientTests.cs @@ -0,0 +1,436 @@ +namespace Cloudflare.NET.Tests.UnitTests; + +using System.Net; +using Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Logging; +using Moq.Protected; +using Shared.Fixtures; +using Xunit.Abstractions; + +/// +/// Contains unit tests for the dynamic client creation feature, specifically the +/// overload. +/// +[Trait("Category", TestConstants.TestCategories.Unit)] +public class DynamicClientTests +{ + #region Properties & Fields - Non-Public + + private readonly ITestOutputHelper _output; + + #endregion + + + #region Constructors + + public DynamicClientTests(ITestOutputHelper output) + { + _output = output; + } + + #endregion + + + #region Methods - Basic Functionality Tests + + /// Verifies that a dynamic client can be created from options without pre-registration. + [Fact] + public void CreateClient_WithValidOptions_ReturnsConfiguredClient() + { + // Arrange + var services = CreateServiceCollection(); + + // Only register the factory (via AddCloudflareApiClient with a dummy client). + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + ApiToken = "dynamic-token", + AccountId = "dynamic-account" + }; + + // Act + var client = factory.CreateClient(options); + + // Assert + client.Should().NotBeNull(); + client.Accounts.Should().NotBeNull(); + client.Zones.Should().NotBeNull(); + client.Dns.Should().NotBeNull(); + } + + + /// Verifies that a dynamic client implements IDisposable for cleanup. + [Fact] + public void CreateClient_WithValidOptions_ReturnsDisposableClient() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + ApiToken = "dynamic-token" + }; + + // Act + var client = factory.CreateClient(options); + + // Assert + client.Should().BeAssignableTo(); + } + + + /// Verifies that disposing a dynamic client releases resources properly. + [Fact] + public void CreateClient_Dispose_ReleasesResources() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + ApiToken = "dynamic-token" + }; + + var client = factory.CreateClient(options); + + // Act + client.Dispose(); + + // Assert - After disposal, accessing properties should throw ObjectDisposedException. + var action = () => _ = client.Accounts; + action.Should().Throw(); + } + + + /// Verifies that multiple dynamic clients can be created independently. + [Fact] + public void CreateClient_MultipleCalls_ReturnsDistinctClients() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options1 = new CloudflareApiOptions { ApiToken = "token-1", AccountId = "account-1" }; + var options2 = new CloudflareApiOptions { ApiToken = "token-2", AccountId = "account-2" }; + + // Act + var client1 = factory.CreateClient(options1); + var client2 = factory.CreateClient(options2); + + // Assert + client1.Should().NotBeSameAs(client2); + + // Cleanup + client1.Dispose(); + client2.Dispose(); + } + + #endregion + + + #region Methods - Validation Tests + + /// Verifies that CreateClient throws ArgumentNullException when options is null. + [Fact] + public void CreateClient_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + // Act + var action = () => factory.CreateClient((CloudflareApiOptions)null!); + + // Assert + action.Should().Throw() + .WithParameterName("options"); + } + + + /// Verifies that CreateClient throws InvalidOperationException when ApiToken is missing. + [Fact] + public void CreateClient_WithMissingApiToken_ThrowsInvalidOperationException() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + AccountId = "some-account" + // ApiToken is intentionally not set + }; + + // Act + var action = () => factory.CreateClient(options); + + // Assert + action.Should().Throw() + .WithMessage("*ApiToken*required*"); + } + + #endregion + + + #region Methods - Configuration Tests + + /// Verifies that a dynamic client uses the correct base URL from options. + [Fact] + public async Task CreateClient_UsesCorrectBaseUrl() + { + // Arrange + const string customBaseUrl = "https://custom.cloudflare.api/v4/"; + + HttpRequestMessage? capturedRequest = null; + var mockHandler = CreateMockHandler(req => capturedRequest = req); + + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + // We can't easily inject the mock handler into a dynamic client, + // so we test by checking the client is created without error. + // The actual HTTP behavior would be tested in integration tests. + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + ApiToken = "test-token", + ApiBaseUrl = customBaseUrl + }; + + // Act + var client = factory.CreateClient(options); + + // Assert - Client was created successfully with custom base URL. + client.Should().NotBeNull(); + + // Cleanup + client.Dispose(); + } + + + /// Verifies that a dynamic client uses the default API base URL when not specified. + [Fact] + public void CreateClient_WithDefaultBaseUrl_UsesCloudflareApiUrl() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + ApiToken = "test-token" + // ApiBaseUrl uses default value + }; + + // Act - Should not throw, meaning default base URL is valid. + var client = factory.CreateClient(options); + + // Assert + client.Should().NotBeNull(); + + // Cleanup + client.Dispose(); + } + + + /// Verifies that a dynamic client can be configured with custom rate limiting options. + [Fact] + public void CreateClient_WithCustomRateLimitingOptions_CreatesClientSuccessfully() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + ApiToken = "test-token", + RateLimiting = new RateLimitingOptions + { + IsEnabled = true, + PermitLimit = 5, + QueueLimit = 10, + MaxRetries = 3 + } + }; + + // Act + var client = factory.CreateClient(options); + + // Assert + client.Should().NotBeNull(); + + // Cleanup + client.Dispose(); + } + + + /// Verifies that a dynamic client can be configured with custom timeout. + [Fact] + public void CreateClient_WithCustomTimeout_CreatesClientSuccessfully() + { + // Arrange + var services = CreateServiceCollection(); + services.AddCloudflareApiClient("dummy", o => o.ApiToken = "dummy-token"); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var options = new CloudflareApiOptions + { + ApiToken = "test-token", + DefaultTimeout = TimeSpan.FromSeconds(60) + }; + + // Act + var client = factory.CreateClient(options); + + // Assert + client.Should().NotBeNull(); + + // Cleanup + client.Dispose(); + } + + #endregion + + + #region Methods - Coexistence Tests + + /// Verifies that dynamic clients can coexist with named clients. + [Fact] + public void DynamicClient_CoexistsWithNamedClients() + { + // Arrange + var services = CreateServiceCollection(); + + services.AddCloudflareApiClient("named", options => + { + options.AccountId = "named-account"; + options.ApiToken = "named-token"; + }); + + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + + var dynamicOptions = new CloudflareApiOptions + { + ApiToken = "dynamic-token", + AccountId = "dynamic-account" + }; + + // Act + var namedClient = factory.CreateClient("named"); + var dynamicClient = factory.CreateClient(dynamicOptions); + + // Assert + namedClient.Should().NotBeNull(); + dynamicClient.Should().NotBeNull(); + namedClient.Should().NotBeSameAs(dynamicClient); + + // Cleanup + dynamicClient.Dispose(); + } + + + /// Verifies that dynamic clients can coexist with the default client. + [Fact] + public void DynamicClient_CoexistsWithDefaultClient() + { + // Arrange + var services = CreateServiceCollection(); + + services.AddCloudflareApiClient(options => + { + options.AccountId = "default-account"; + options.ApiToken = "default-token"; + }); + + var serviceProvider = services.BuildServiceProvider(); + var defaultClient = serviceProvider.GetRequiredService(); + var factory = serviceProvider.GetRequiredService(); + + var dynamicOptions = new CloudflareApiOptions + { + ApiToken = "dynamic-token", + AccountId = "dynamic-account" + }; + + // Act + var dynamicClient = factory.CreateClient(dynamicOptions); + + // Assert + defaultClient.Should().NotBeNull(); + dynamicClient.Should().NotBeNull(); + defaultClient.Should().NotBeSameAs(dynamicClient); + + // Cleanup + dynamicClient.Dispose(); + } + + #endregion + + + #region Methods - Helper + + /// Creates a service collection with common test dependencies. + private ServiceCollection CreateServiceCollection() + { + var services = new ServiceCollection(); + + // Add logging that pipes to xUnit test output. + services.AddLogging(builder => builder.AddProvider(new XunitTestOutputLoggerProvider { Current = _output })); + + return services; + } + + + /// Creates a mock HTTP message handler that captures requests. + private Mock CreateMockHandler(Action? onRequest = null) + { + var mockHandler = new Mock(); + + mockHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Callback((req, _) => onRequest?.Invoke(req)) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(HttpFixtures.CreateSuccessResponse(null)) + }); + + return mockHandler; + } + + #endregion +} From b384693cba53b649d8495955127e07a8d824f864 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:17:12 +0100 Subject: [PATCH 2/5] Version bump --- src/Cloudflare.NET/Cloudflare.NET.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cloudflare.NET/Cloudflare.NET.csproj b/src/Cloudflare.NET/Cloudflare.NET.csproj index 99d9268..4fcf457 100644 --- a/src/Cloudflare.NET/Cloudflare.NET.csproj +++ b/src/Cloudflare.NET/Cloudflare.NET.csproj @@ -5,7 +5,7 @@ Cloudflare.NET.Api - 3.3.0 + 3.4.0 Cloudflare.NET - A comprehensive C# client library for the Cloudflare REST API. Manage DNS records, Zones, R2 buckets, Workers, WAF rules, Turnstile, and security features with strongly-typed .NET code. cloudflare;cloudflare-api;cloudflare-sdk;cloudflare-client;dotnet;csharp;dns;r2;waf;firewall;zone;workers;turnstile;api-client;rest-client From 683dcaa1f71e4263ca1da3dfa180f64f910604a0 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:22:38 +0100 Subject: [PATCH 3/5] docs --- docs/articles/configuration.md | 74 ++++++++++++++++++++- docs/articles/getting-started.md | 109 ++++++++++++++++++++++--------- 2 files changed, 150 insertions(+), 33 deletions(-) diff --git a/docs/articles/configuration.md b/docs/articles/configuration.md index 74ba6a1..fbf071f 100644 --- a/docs/articles/configuration.md +++ b/docs/articles/configuration.md @@ -192,7 +192,7 @@ The resilience pipeline is configured automatically when using `AddCloudflareApi ## Named Clients -For multi-account scenarios, use named clients with separate configurations: +For multi-account scenarios where configurations are known at startup, use named clients: ```csharp // appsettings.json @@ -212,6 +212,78 @@ builder.Services.AddCloudflareApiClient("production", builder.Configuration); builder.Services.AddCloudflareApiClient("staging", builder.Configuration); ``` +## Dynamic Clients + +For scenarios where client configurations are not known at startup (e.g., desktop applications where users add accounts at runtime), use dynamic client creation: + +```csharp +public class AccountManager(ICloudflareApiClientFactory factory) +{ + public async Task ValidateAndGetAccountInfoAsync(string apiToken, string accountId) + { + var options = new CloudflareApiOptions + { + ApiToken = apiToken, + AccountId = accountId, + DefaultTimeout = TimeSpan.FromSeconds(15), + RateLimiting = new RateLimitingOptions + { + IsEnabled = true, + PermitLimit = 5, // Conservative for user-provided credentials + MaxRetries = 2 + } + }; + + // Create and use a dynamic client + using var client = factory.CreateClient(options); + + var user = await client.User.GetUserAsync(); + return new AccountInfo(user.Email, accountId); + } +} +``` + +### Dynamic Client Characteristics + +| Aspect | Named Clients | Dynamic Clients | +|--------|---------------|-----------------| +| Configuration | At startup via DI | At runtime via `CloudflareApiOptions` | +| Lifecycle | Managed by DI container | Must be disposed by caller | +| HttpClient | Shared via `IHttpClientFactory` | Owned by the client instance | +| Resilience Pipeline | Shared per named client | Isolated per instance | +| Use Case | Known accounts, server apps | User-provided accounts, desktop apps | + +### Disposal Requirements + +Dynamic clients implement `IDisposable` and **must** be disposed when no longer needed: + +```csharp +// Option 1: using statement (recommended) +using var client = factory.CreateClient(options); +await client.Zones.ListZonesAsync(); +// Client automatically disposed at end of scope + +// Option 2: using declaration in async methods +using var client = factory.CreateClient(options); +var zones = await client.Zones.ListZonesAsync(); +// ... more operations +// Client disposed when method returns + +// Option 3: Manual disposal (for longer-lived clients) +var client = factory.CreateClient(options); +try +{ + // Use client for multiple operations... +} +finally +{ + client.Dispose(); +} +``` + +> [!WARNING] +> Failing to dispose dynamic clients will leak `HttpClient` instances and their underlying socket connections. Always use a `using` statement or explicitly call `Dispose()`. + ## Environment Variables Configuration can also be provided via environment variables using the `__` (double underscore) convention: diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index f98d14e..b0068f4 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -41,6 +41,40 @@ Install-Package Cloudflare.NET.Analytics ## Configuration +The SDK supports two configuration approaches: + +1. **Dependency Injection** (recommended for most applications) - Register clients at startup with `AddCloudflareApiClient()` +2. **Dynamic Creation** - Create clients at runtime when configurations aren't known at startup (see [Dynamic Clients](#option-2-dynamic-clients-runtime-creation)) + +### Dependency Injection + +Register the clients in your `Program.cs`: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Register from IConfiguration (binds to "Cloudflare" and "R2" sections) +builder.Services.AddCloudflareApiClient(builder.Configuration); +builder.Services.AddCloudflareR2Client(builder.Configuration); +builder.Services.AddCloudflareAnalytics(); + +var app = builder.Build(); +``` + +Alternatively, configure options programmatically: + +```csharp +builder.Services.AddCloudflareApiClient(options => +{ + options.ApiToken = "your-api-token"; + options.AccountId = "your-account-id"; + options.DefaultTimeout = TimeSpan.FromSeconds(30); + options.RateLimiting.IsEnabled = true; + options.RateLimiting.EnableProactiveThrottling = true; // Delay requests when quota is low + options.RateLimiting.QuotaLowThreshold = 0.1; // Throttle at 10% remaining +}); +``` + ### appsettings.json Configure your Cloudflare credentials in `appsettings.json`: @@ -73,35 +107,6 @@ Configure your Cloudflare credentials in `appsettings.json`: > [!NOTE] > Never commit API tokens or secrets to source control. Use [User Secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) for development and environment variables or a managed Key Vault for production. -### Dependency Injection - -Register the clients in your `Program.cs`: - -```csharp -var builder = WebApplication.CreateBuilder(args); - -// Register from IConfiguration (binds to "Cloudflare" and "R2" sections) -builder.Services.AddCloudflareApiClient(builder.Configuration); -builder.Services.AddCloudflareR2Client(builder.Configuration); -builder.Services.AddCloudflareAnalytics(); - -var app = builder.Build(); -``` - -Alternatively, configure options programmatically: - -```csharp -builder.Services.AddCloudflareApiClient(options => -{ - options.ApiToken = "your-api-token"; - options.AccountId = "your-account-id"; - options.DefaultTimeout = TimeSpan.FromSeconds(30); - options.RateLimiting.IsEnabled = true; - options.RateLimiting.EnableProactiveThrottling = true; // Delay requests when quota is low - options.RateLimiting.QuotaLowThreshold = 0.1; // Throttle at 10% remaining -}); -``` - ## Basic Usage Inject into your services: @@ -131,7 +136,11 @@ public class DnsService(ICloudflareApiClient cf) ## Multi-Account Support -For applications managing multiple Cloudflare accounts, use named clients: +For applications managing multiple Cloudflare accounts, you have two options: + +### Option 1: Named Clients (Pre-Registered) + +Use named clients when account configurations are known at startup: ```csharp // Register named clients @@ -148,7 +157,7 @@ builder.Services.AddCloudflareApiClient("staging", options => }); ``` -### Using the Factory +#### Using the Factory ```csharp public class MultiAccountService(ICloudflareApiClientFactory apiFactory) @@ -161,7 +170,7 @@ public class MultiAccountService(ICloudflareApiClientFactory apiFactory) } ``` -### Using Keyed Services (.NET 8+) +#### Using Keyed Services (.NET 8+) ```csharp public class MyService( @@ -172,6 +181,42 @@ public class MyService( } ``` +### Option 2: Dynamic Clients (Runtime Creation) + +Use dynamic clients when account configurations are not known at startup, such as when users can add Cloudflare accounts through a UI: + +```csharp +public class UserAccountService(ICloudflareApiClientFactory factory) +{ + public async Task> GetUserZonesAsync(UserCloudflareCredentials credentials) + { + var options = new CloudflareApiOptions + { + ApiToken = credentials.ApiToken, + AccountId = credentials.AccountId, + RateLimiting = new RateLimitingOptions + { + IsEnabled = true, + PermitLimit = 10 // Conservative limit for user accounts + } + }; + + // Dynamic clients must be disposed when done + using var client = factory.CreateClient(options); + + var zones = new List(); + await foreach (var zone in client.Zones.ListAllZonesAsync()) + { + zones.Add(zone); + } + return zones; + } +} +``` + +> [!IMPORTANT] +> Dynamic clients manage their own `HttpClient` and resilience pipeline. Always dispose them when finished using a `using` statement or by calling `Dispose()`. Each dynamic client has isolated state (rate limiter, circuit breaker) and does not share resources with other clients. + ## Error Handling The SDK throws when the API returns an error: From 754c8db63469281aceee008647ee86f58eac3f27 Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:42:12 +0100 Subject: [PATCH 4/5] Updated minimum AWSSDK.Core version to 4.0.3.3 due to vulnerability --- src/Cloudflare.NET.R2/Cloudflare.NET.R2.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Cloudflare.NET.R2/Cloudflare.NET.R2.csproj b/src/Cloudflare.NET.R2/Cloudflare.NET.R2.csproj index 20c54eb..48f1d68 100644 --- a/src/Cloudflare.NET.R2/Cloudflare.NET.R2.csproj +++ b/src/Cloudflare.NET.R2/Cloudflare.NET.R2.csproj @@ -18,6 +18,8 @@ + + From b55134712a95cb852ffa096c424b97943a776ecf Mon Sep 17 00:00:00 2001 From: Alexis <3876562+alexis-@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:18:18 +0100 Subject: [PATCH 5/5] Cloudflare changed their status code --- .../Fixtures/CloudflareApiTestFixture.cs | 2 +- ...AccountSubscriptionsApiIntegrationTests.cs | 6 +++--- .../ZoneSubscriptionsApiIntegrationTests.cs | 19 ++++++++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/Cloudflare.NET.Tests/Fixtures/CloudflareApiTestFixture.cs b/tests/Cloudflare.NET.Tests/Fixtures/CloudflareApiTestFixture.cs index 10fd5c2..5d6d236 100644 --- a/tests/Cloudflare.NET.Tests/Fixtures/CloudflareApiTestFixture.cs +++ b/tests/Cloudflare.NET.Tests/Fixtures/CloudflareApiTestFixture.cs @@ -144,7 +144,7 @@ public async Task InitializeAsync() // This ensures validation completes before any tests run, even with parallelization. PermissionValidationRunner.InitializeAccountValidation(ApiTokensApi, _settings.AccountId); await PermissionValidationRunner.EnsureAccountValidationAsync(); - + // NOTE: Uncomment if you want to skip the test if user validation hasn't run yet. /*Skip.If( !PermissionValidationState.UserValidationCompleted, diff --git a/tests/Cloudflare.NET.Tests/IntegrationTests/AccountSubscriptionsApiIntegrationTests.cs b/tests/Cloudflare.NET.Tests/IntegrationTests/AccountSubscriptionsApiIntegrationTests.cs index 71c363b..e1fea9a 100644 --- a/tests/Cloudflare.NET.Tests/IntegrationTests/AccountSubscriptionsApiIntegrationTests.cs +++ b/tests/Cloudflare.NET.Tests/IntegrationTests/AccountSubscriptionsApiIntegrationTests.cs @@ -281,7 +281,7 @@ await act.Should() /// I11: Verifies that invalid rate plan returns API error. [IntegrationTest] - public async Task CreateAccountSubscriptionAsync_InvalidRatePlan_ThrowsNotFound() + public async Task CreateAccountSubscriptionAsync_InvalidRatePlan_ThrowsBadRequest() { // Arrange var accountId = _settings.AccountId; @@ -291,10 +291,10 @@ public async Task CreateAccountSubscriptionAsync_InvalidRatePlan_ThrowsNotFound( // Act var act = () => _sut.CreateAccountSubscriptionAsync(accountId, request); - // Assert - Per Cloudflare API: Invalid rate plan references return 404 Not Found + // Assert - Invalid rate plan references return 400 Bad Request with error code 7501. await act.Should() .ThrowAsync() - .Where(ex => ex.StatusCode == System.Net.HttpStatusCode.NotFound); + .Where(ex => ex.StatusCode == System.Net.HttpStatusCode.BadRequest); } /// I12: Verifies that malformed subscription ID returns API error. diff --git a/tests/Cloudflare.NET.Tests/IntegrationTests/ZoneSubscriptionsApiIntegrationTests.cs b/tests/Cloudflare.NET.Tests/IntegrationTests/ZoneSubscriptionsApiIntegrationTests.cs index 868c7a8..530cf34 100644 --- a/tests/Cloudflare.NET.Tests/IntegrationTests/ZoneSubscriptionsApiIntegrationTests.cs +++ b/tests/Cloudflare.NET.Tests/IntegrationTests/ZoneSubscriptionsApiIntegrationTests.cs @@ -268,14 +268,19 @@ await act.Should() .Where(ex => ex.StatusCode == System.Net.HttpStatusCode.NotFound); } - /// I12: Verifies that create subscription with invalid rate plan returns 404. + /// I12: Verifies that create subscription with invalid rate plan returns 400 Bad Request. /// - /// Per Cloudflare API: POST with non-existent rate plan returns 404 Not Found. - /// Error code 1298: "Review the rate plan ID and try again. Could not find the rate plan." - /// https://developers.cloudflare.com/api/resources/zones/subresources/subscriptions/methods/create/ + /// + /// Per Cloudflare API: POST with non-existent rate plan returns 400 Bad Request with error code 7501. + /// Error message: "unknown or deprecated rate plan: 'invalid-rate-plan-that-does-not-exist'" + /// + /// + /// Note: The documentation previously indicated 404, but current behavior returns 400. + /// If this test fails with 404, Cloudflare may have reverted to the documented behavior. + /// /// [IntegrationTest] - public async Task CreateZoneSubscriptionAsync_InvalidRatePlan_ThrowsNotFound() + public async Task CreateZoneSubscriptionAsync_InvalidRatePlan_ThrowsBadRequest() { // Arrange var zoneId = _settings.ZoneId; @@ -285,10 +290,10 @@ public async Task CreateZoneSubscriptionAsync_InvalidRatePlan_ThrowsNotFound() // Act var act = () => _sut.CreateZoneSubscriptionAsync(zoneId, request); - // Assert - Non-existent rate plan returns 404 Not Found + // Assert - Invalid rate plan references return 400 Bad Request with error code 7501. await act.Should() .ThrowAsync() - .Where(ex => ex.StatusCode == System.Net.HttpStatusCode.NotFound); + .Where(ex => ex.StatusCode == System.Net.HttpStatusCode.BadRequest); } /// I13: Verifies that update subscription on non-existent zone returns 404.