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();
}
}