354 lines
13 KiB
C#
Executable File
354 lines
13 KiB
C#
Executable File
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace MarketAlly.AIPlugin.ClaudeCode;
|
|
|
|
/// <summary>
|
|
/// HTTP client implementation with intelligent rate limiting
|
|
/// </summary>
|
|
public class RateLimitAwareHttpClient : IRateLimitAwareHttpClient, IDisposable
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<RateLimitAwareHttpClient> _logger;
|
|
private readonly ClaudeCodeOptions _options;
|
|
private readonly RateLimitOptions _rateLimitOptions;
|
|
private RateLimitInfo? _lastKnownRateLimit;
|
|
private readonly SemaphoreSlim _requestSemaphore = new(1, 1);
|
|
|
|
public RateLimitAwareHttpClient(
|
|
HttpClient httpClient,
|
|
ILogger<RateLimitAwareHttpClient> logger,
|
|
IOptions<ClaudeCodeOptions> options,
|
|
IOptions<RateLimitOptions> 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<RateLimitAwareResponse> GetAsync(string endpoint, CancellationToken cancellationToken = default)
|
|
{
|
|
return await ExecuteWithRetryAsync(async () =>
|
|
{
|
|
var response = await _httpClient.GetAsync(endpoint, cancellationToken);
|
|
return await ProcessHttpResponseAsync(response);
|
|
});
|
|
}
|
|
|
|
public async Task<RateLimitAwareResponse> 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<RateLimitAwareResponse> PostAsJsonAsync<T>(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<RateLimitAwareResponse> 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<RateLimitAwareResponse> DeleteAsync(string endpoint, CancellationToken cancellationToken = default)
|
|
{
|
|
return await ExecuteWithRetryAsync(async () =>
|
|
{
|
|
var response = await _httpClient.DeleteAsync(endpoint, cancellationToken);
|
|
return await ProcessHttpResponseAsync(response);
|
|
});
|
|
}
|
|
|
|
public async Task<RateLimitInfo> 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<bool> 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<bool> 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<RateLimitAwareResponse> ExecuteWithRetryAsync(Func<Task<RateLimitAwareResponse>> 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<RateLimitAwareResponse> 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();
|
|
}
|
|
} |