DeutschLernen/GermanApp/Infrastructure/Services/MistralConnector.cs
Lasse Rune Hansen 57e51d0d0b
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
test(backend/infrastructure): add unit tests for MistralConnector
- Add Tests/Unit/Infrastructure/Services/MistralConnectorTests.cs with 20 tests
  - Constructor validation tests
  - Configuration validation tests
  - Model factory method tests
  - Rate limiter tests
  - Circuit breaker tests
- Fix HttpClient Content-Type header issue (use Accept header instead)
- All 137 unit tests passing (117 previous + 20 new)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-09 20:00:14 +02:00

244 lines
9.2 KiB
C#

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;
/// <summary>
/// HTTP client implementation for Mistral API.
/// Implements IMistralConnector from Domain layer.
/// </summary>
public class MistralConnector : IMistralConnector
{
private readonly HttpClient _httpClient;
private readonly MistralConfig _config;
private readonly ILogger<MistralConnector> _logger;
private readonly MistralRateLimiter _rateLimiter;
private readonly IMemoryCache _cache;
private readonly MistralCircuitBreaker _circuitBreaker;
private readonly JsonSerializerOptions _jsonOptions;
public MistralConnector(HttpClient httpClient, MistralConfig config,
ILogger<MistralConnector> 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.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("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<MistralResponse> CompleteAsync(MistralRequest request,
CancellationToken cancellationToken = default)
{
return await ExecuteRequestAsync<MistralRequest, MistralResponse>(
"completions", request, cancellationToken);
}
public async Task<MistralResponse> ChatAsync(MistralChatRequest request,
CancellationToken cancellationToken = default)
{
return await ExecuteRequestAsync<MistralChatRequest, MistralResponse>(
"chat/completions", request, cancellationToken);
}
public async Task<IReadOnlyList<MistralModel>> ListModelsAsync(
CancellationToken cancellationToken = default)
{
CheckCircuitAndRateLimit("models");
try
{
var response = await ExecuteWithRetryAsync(async () =>
{
var httpResponse = await _httpClient.GetAsync("models", cancellationToken);
return await HandleResponseAsync<MistralListModelsResponse>(httpResponse);
}, "ListModels");
_circuitBreaker.RecordSuccess();
return response.Data;
}
catch (Exception ex)
{
HandleRequestError(ex, "ListModels");
throw;
}
}
public async Task<MistralModel> 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<MistralModel>(httpResponse);
}, "GetModel");
_circuitBreaker.RecordSuccess();
return response;
}
catch (Exception ex)
{
HandleRequestError(ex, "GetModel");
throw;
}
}
private async Task<TResponse> ExecuteRequestAsync<TRequest, TResponse>(
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<TResponse>(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<T> ExecuteWithRetryAsync<T>(Func<Task<T>> 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<T> HandleResponseAsync<T>(HttpResponseMessage httpResponse)
{
if (!httpResponse.IsSuccessStatusCode)
{
var errorContent = await httpResponse.Content.ReadAsStringAsync();
var errorResponse = JsonSerializer.Deserialize<MistralErrorResponse>(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<T>(content, _jsonOptions)
?? throw new AiServiceException("Invalid response from Mistral API",
AiErrorCode.InvalidResponse);
}
private string GenerateCacheKey<T>(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);
}
}
}