using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Net; using System.Text; using System.Text.Json; namespace MarketAlly.AIPlugin.ClaudeCode; /// /// HTTP client implementation with intelligent rate limiting /// public class RateLimitAwareHttpClient : IRateLimitAwareHttpClient, IDisposable { private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly ClaudeCodeOptions _options; private readonly RateLimitOptions _rateLimitOptions; private RateLimitInfo? _lastKnownRateLimit; private readonly SemaphoreSlim _requestSemaphore = new(1, 1); public RateLimitAwareHttpClient( HttpClient httpClient, ILogger logger, IOptions options, IOptions rateLimitOptions) { _httpClient = httpClient; _logger = logger; _options = options.Value; _rateLimitOptions = rateLimitOptions.Value; // Configure default timeout _httpClient.Timeout = TimeSpan.FromMinutes(5); } public void Configure(string baseUrl, string apiKey, string? tenantId = null) { _httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); _httpClient.DefaultRequestHeaders.Clear(); _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); _httpClient.DefaultRequestHeaders.Add("User-Agent", "MarketAlly.ClaudeCode/1.0.0"); if (!string.IsNullOrEmpty(tenantId)) { _httpClient.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId); } _logger.LogDebug("HTTP client configured for {BaseUrl}", baseUrl); } public async Task GetAsync(string endpoint, CancellationToken cancellationToken = default) { return await ExecuteWithRetryAsync(async () => { var response = await _httpClient.GetAsync(endpoint, cancellationToken); return await ProcessHttpResponseAsync(response); }); } public async Task PostAsync(string endpoint, HttpContent? content = null, CancellationToken cancellationToken = default) { return await ExecuteWithRetryAsync(async () => { var response = await _httpClient.PostAsync(endpoint, content, cancellationToken); return await ProcessHttpResponseAsync(response); }); } public async Task PostAsJsonAsync(string endpoint, T data, CancellationToken cancellationToken = default) { var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var content = new StringContent(json, Encoding.UTF8, "application/json"); return await PostAsync(endpoint, content, cancellationToken); } public async Task PutAsync(string endpoint, HttpContent? content = null, CancellationToken cancellationToken = default) { return await ExecuteWithRetryAsync(async () => { var response = await _httpClient.PutAsync(endpoint, content, cancellationToken); return await ProcessHttpResponseAsync(response); }); } public async Task DeleteAsync(string endpoint, CancellationToken cancellationToken = default) { return await ExecuteWithRetryAsync(async () => { var response = await _httpClient.DeleteAsync(endpoint, cancellationToken); return await ProcessHttpResponseAsync(response); }); } public async Task GetRateLimitStatusAsync(CancellationToken cancellationToken = default) { try { var response = await GetAsync("/api/rate-limit/status", cancellationToken); if (response.IsSuccessStatusCode && response.RateLimitInfo != null) { _lastKnownRateLimit = response.RateLimitInfo; return response.RateLimitInfo; } // Fallback to last known status return _lastKnownRateLimit ?? new RateLimitInfo { Tier = "Unknown", Current = 0, Limit = 100, ResetTime = DateTime.UtcNow.AddHours(1) }; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to get rate limit status"); return _lastKnownRateLimit ?? new RateLimitInfo { Tier = "Unknown", Current = 0, Limit = 100, ResetTime = DateTime.UtcNow.AddHours(1) }; } } public async Task CanMakeRequestAsync(string? endpoint = null) { var rateLimitInfo = await GetRateLimitStatusAsync(); // Check if we're near the limit var usagePercentage = (double)rateLimitInfo.Current / rateLimitInfo.Limit * 100; if (usagePercentage >= 95) // 95% usage threshold { _logger.LogWarning("Rate limit nearly exceeded: {Current}/{Limit} ({Percentage:F1}%)", rateLimitInfo.Current, rateLimitInfo.Limit, usagePercentage); return false; } return true; } public async Task WaitForRateLimitResetAsync(TimeSpan? maxWaitTime = null, CancellationToken cancellationToken = default) { var rateLimitInfo = await GetRateLimitStatusAsync(cancellationToken); var waitTime = rateLimitInfo.TimeToReset; var maxWait = maxWaitTime ?? _rateLimitOptions.MaxRetryWait; if (waitTime > maxWait) { _logger.LogWarning("Rate limit reset time ({WaitTime}) exceeds maximum wait time ({MaxWait})", waitTime, maxWait); return false; } if (waitTime > TimeSpan.Zero) { _logger.LogInformation("Waiting {WaitTime} for rate limit reset", waitTime); if (_rateLimitOptions.EnableProgressIndicator) { await WaitWithProgressAsync(waitTime, cancellationToken); } else { await Task.Delay(waitTime, cancellationToken); } return true; } return true; } private async Task ExecuteWithRetryAsync(Func> operation) { var retryCount = 0; var baseDelay = _options.BaseRetryDelay; while (retryCount <= _options.MaxRetries) { await _requestSemaphore.WaitAsync(); try { // Check if we can make the request if (_rateLimitOptions.RespectRateLimits && retryCount == 0) { var canMakeRequest = await CanMakeRequestAsync(); if (!canMakeRequest) { var waited = await WaitForRateLimitResetAsync(); if (!waited) { return RateLimitAwareResponse.Error(429, "Rate limit exceeded and wait time too long", _lastKnownRateLimit); } } } var response = await operation(); // Update rate limit info if (response.RateLimitInfo != null) { _lastKnownRateLimit = response.RateLimitInfo; } // Check for rate limiting if (response.IsRateLimited && _rateLimitOptions.AutoRetry) { if (retryCount < _options.MaxRetries) { var waitTime = response.RetryAfter ?? CalculateBackoffDelay(retryCount, baseDelay); _logger.LogWarning("Rate limited, retrying in {WaitTime} (attempt {RetryCount}/{MaxRetries})", waitTime, retryCount + 1, _options.MaxRetries); await Task.Delay(waitTime); retryCount++; continue; } else { _logger.LogError("Rate limit exceeded and max retries reached"); return response; } } // Success or non-retryable error return response; } finally { _requestSemaphore.Release(); } } return RateLimitAwareResponse.Error(429, "Max retries exceeded", _lastKnownRateLimit); } private async Task ProcessHttpResponseAsync(HttpResponseMessage response) { var content = await response.Content.ReadAsStringAsync(); var headers = response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)); // Parse rate limit headers RateLimitInfo? rateLimitInfo = null; if (response.Headers.Contains("X-RateLimit-Limit") && response.Headers.Contains("X-RateLimit-Remaining")) { rateLimitInfo = ParseRateLimitHeaders(response.Headers); } var result = new RateLimitAwareResponse { IsSuccessStatusCode = response.IsSuccessStatusCode, StatusCode = (int)response.StatusCode, Content = content, Headers = headers, RateLimitInfo = rateLimitInfo, IsRateLimited = response.StatusCode == HttpStatusCode.TooManyRequests }; if (!response.IsSuccessStatusCode) { result.ErrorMessage = $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; if (response.StatusCode == HttpStatusCode.TooManyRequests) { if (response.Headers.RetryAfter != null) { result.RetryAfter = response.Headers.RetryAfter.Delta; } } } return result; } private RateLimitInfo ParseRateLimitHeaders(System.Net.Http.Headers.HttpResponseHeaders headers) { var rateLimitInfo = new RateLimitInfo(); if (headers.TryGetValues("X-RateLimit-Limit", out var limitValues) && int.TryParse(limitValues.First(), out var limit)) { rateLimitInfo.Limit = limit; } if (headers.TryGetValues("X-RateLimit-Remaining", out var remainingValues) && int.TryParse(remainingValues.First(), out var remaining)) { rateLimitInfo.Current = rateLimitInfo.Limit - remaining; } if (headers.TryGetValues("X-RateLimit-Reset", out var resetValues) && long.TryParse(resetValues.First(), out var resetUnix)) { rateLimitInfo.ResetTime = DateTimeOffset.FromUnixTimeSeconds(resetUnix).DateTime; } if (headers.TryGetValues("X-RateLimit-Tier", out var tierValues)) { rateLimitInfo.Tier = tierValues.First(); } rateLimitInfo.TimeToReset = rateLimitInfo.ResetTime - DateTime.UtcNow; if (rateLimitInfo.TimeToReset < TimeSpan.Zero) rateLimitInfo.TimeToReset = TimeSpan.Zero; rateLimitInfo.PercentageUsed = rateLimitInfo.Limit > 0 ? (double)rateLimitInfo.Current / rateLimitInfo.Limit * 100 : 0; rateLimitInfo.IsNearLimit = rateLimitInfo.PercentageUsed >= 80; return rateLimitInfo; } private TimeSpan CalculateBackoffDelay(int retryCount, TimeSpan baseDelay) { var delay = TimeSpan.FromMilliseconds( baseDelay.TotalMilliseconds * Math.Pow(_options.BackoffMultiplier, retryCount)); return delay > _options.MaxRetryDelay ? _options.MaxRetryDelay : delay; } private async Task WaitWithProgressAsync(TimeSpan waitTime, CancellationToken cancellationToken) { var totalSeconds = (int)waitTime.TotalSeconds; var progressInterval = TimeSpan.FromSeconds(Math.Max(1, totalSeconds / 20)); // Update progress 20 times for (var elapsed = TimeSpan.Zero; elapsed < waitTime; elapsed += progressInterval) { var remaining = waitTime - elapsed; var percentage = (elapsed.TotalSeconds / waitTime.TotalSeconds) * 100; _logger.LogInformation("Rate limit wait progress: {Percentage:F1}% - {Remaining} remaining", percentage, remaining); var delayTime = remaining < progressInterval ? remaining : progressInterval; await Task.Delay(delayTime, cancellationToken); } } public void Dispose() { _requestSemaphore?.Dispose(); _httpClient?.Dispose(); } }