using System.Net;
using System.Text;
using System.Text.Json;
using GermanApp.Application.Models;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace GermanApp.Infrastructure.Services;
///
/// HTTP client implementation for Mistral API.
/// Implements IMistralConnector from Domain layer.
///
public class MistralConnector : IMistralConnector
{
private readonly HttpClient _httpClient;
private readonly MistralConfig _config;
private readonly ILogger _logger;
private readonly MistralRateLimiter _rateLimiter;
private readonly IMemoryCache _cache;
private readonly MistralCircuitBreaker _circuitBreaker;
private readonly JsonSerializerOptions _jsonOptions;
public MistralConnector(HttpClient httpClient, MistralConfig config,
ILogger logger, IMemoryCache cache)
{
_httpClient = httpClient;
_config = config;
_logger = logger;
_cache = cache;
_config.Validate();
_httpClient.BaseAddress = new Uri(_config.BaseUrl);
_httpClient.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds);
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.ApiKey}");
_httpClient.DefaultRequestHeaders.Add("Content-Type", "application/json");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
_rateLimiter = new MistralRateLimiter(_config.RateLimitPerMinute);
_circuitBreaker = new MistralCircuitBreaker(
_config.CircuitBreakerFailureThreshold,
TimeSpan.FromMinutes(_config.CircuitBreakerResetMinutes));
}
public async Task CompleteAsync(MistralRequest request,
CancellationToken cancellationToken = default)
{
return await ExecuteRequestAsync(
"completions", request, cancellationToken);
}
public async Task ChatAsync(MistralChatRequest request,
CancellationToken cancellationToken = default)
{
return await ExecuteRequestAsync(
"chat/completions", request, cancellationToken);
}
public async Task> ListModelsAsync(
CancellationToken cancellationToken = default)
{
CheckCircuitAndRateLimit("models");
try
{
var response = await ExecuteWithRetryAsync(async () =>
{
var httpResponse = await _httpClient.GetAsync("models", cancellationToken);
return await HandleResponseAsync(httpResponse);
}, "ListModels");
_circuitBreaker.RecordSuccess();
return response.Data;
}
catch (Exception ex)
{
HandleRequestError(ex, "ListModels");
throw;
}
}
public async Task GetModelAsync(string modelId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(modelId))
throw new ArgumentException("Model ID cannot be null or empty", nameof(modelId));
CheckCircuitAndRateLimit("models");
try
{
var response = await ExecuteWithRetryAsync(async () =>
{
var httpResponse = await _httpClient.GetAsync($"models/{modelId}", cancellationToken);
return await HandleResponseAsync(httpResponse);
}, "GetModel");
_circuitBreaker.RecordSuccess();
return response;
}
catch (Exception ex)
{
HandleRequestError(ex, "GetModel");
throw;
}
}
private async Task ExecuteRequestAsync(
string endpoint, TRequest request, CancellationToken cancellationToken)
where TResponse : class
{
CheckCircuitAndRateLimit(endpoint);
// Generate cache key for caching
var cacheKey = GenerateCacheKey(request);
if (_config.EnableCaching && _cache.TryGetValue(cacheKey, out TResponse cachedResponse))
{
_logger.LogDebug("Cache hit for {Endpoint}", endpoint);
return cachedResponse!;
}
try
{
var response = await ExecuteWithRetryAsync(async () =>
{
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var httpResponse = await _httpClient.PostAsync(endpoint, content, cancellationToken);
return await HandleResponseAsync(httpResponse);
}, endpoint);
_circuitBreaker.RecordSuccess();
// Cache the response if caching is enabled
if (_config.EnableCaching && response != null)
{
_cache.Set(cacheKey, response, TimeSpan.FromMinutes(_config.CacheTTLMinutes));
}
return response;
}
catch (Exception ex)
{
HandleRequestError(ex, endpoint);
throw;
}
}
private void CheckCircuitAndRateLimit(string endpoint)
{
if (!_circuitBreaker.IsClosed)
throw new AiServiceException("Mistral API circuit breaker is open",
AiErrorCode.ServiceUnavailable);
if (!_rateLimiter.TryAcquire(endpoint))
throw new AiServiceException("Rate limit exceeded",
AiErrorCode.RateLimited);
}
private async Task ExecuteWithRetryAsync(Func> action, string operationName)
{
int retryCount = 0;
while (true)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_config.TimeoutSeconds));
return await action();
}
catch (Exception ex) when (retryCount < _config.MaxRetries && IsRetryable(ex))
{
retryCount++;
var delay = TimeSpan.FromSeconds(Math.Pow(2, retryCount));
_logger.LogWarning(ex, "{Operation} failed (attempt {RetryCount}), retrying in {Delay}s...",
operationName, retryCount, delay.TotalSeconds);
await Task.Delay(delay);
}
}
}
private bool IsRetryable(Exception ex) => ex switch
{
HttpRequestException or TimeoutException or TaskCanceledException => true,
_ => false
};
private async Task HandleResponseAsync(HttpResponseMessage httpResponse)
{
if (!httpResponse.IsSuccessStatusCode)
{
var errorContent = await httpResponse.Content.ReadAsStringAsync();
var errorResponse = JsonSerializer.Deserialize(errorContent, _jsonOptions);
var errorCode = errorResponse?.ErrorCode ?? MapStatusCodeToErrorCode(httpResponse.StatusCode);
var message = errorResponse?.Message ?? $"HTTP {(int)httpResponse.StatusCode}: {httpResponse.ReasonPhrase}";
_logger.LogError("Mistral API error: {StatusCode} - {Message}",
(int)httpResponse.StatusCode, message);
httpResponse.EnsureSuccessStatusCode(); // This will throw
}
var content = await httpResponse.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize(content, _jsonOptions)
?? throw new AiServiceException("Invalid response from Mistral API",
AiErrorCode.InvalidResponse);
}
private string GenerateCacheKey(T request)
{
var json = JsonSerializer.Serialize(request, _jsonOptions);
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return Convert.ToBase64String(hashBytes);
}
private AiErrorCode MapStatusCodeToErrorCode(HttpStatusCode statusCode)
{
return statusCode switch
{
HttpStatusCode.Unauthorized => AiErrorCode.AuthenticationError,
HttpStatusCode.TooManyRequests => AiErrorCode.RateLimited,
HttpStatusCode.RequestTimeout => AiErrorCode.Timeout,
HttpStatusCode.BadRequest => AiErrorCode.InvalidRequest,
HttpStatusCode.InternalServerError or
HttpStatusCode.ServiceUnavailable or
HttpStatusCode.BadGateway or
HttpStatusCode.GatewayTimeout => AiErrorCode.Temporary,
_ => AiErrorCode.Unknown
};
}
private void HandleRequestError(Exception ex, string operationName)
{
_circuitBreaker.RecordFailure();
_logger.LogError(ex, "Mistral API request failed: {Operation}", operationName);
if (ex is not AiServiceException)
{
throw new AiServiceException($"Mistral {operationName} failed: {ex.Message}",
ex, AiErrorCode.Temporary);
}
}
}