fix: connexions HTTP sortantes robustes (BAN/ORS) — blocages intermittents

Les HttpClient vers l'API BAN et ORS réutilisaient des connexions TLS
éventées par le pool .NET, provoquant des blocages jusqu'au timeout
front de 10s (SocketException Operation canceled). Configuration d'un
SocketsHttpHandler dédié avec PooledConnectionLifetime (2 min),
PooledConnectionIdleTimeout (30s) et ConnectTimeout (5s).

Resserre aussi le catch de ORSClient : une annulation provenant de
l'appelant (CancellationToken du contrôleur) est désormais propagée
telle quelle, au lieu d'être maquillée en timeout ORS.
This commit is contained in:
2026-10-13 20:51:39 +02:00
parent 2fa3e35a88
commit 7695deae10
2 changed files with 34 additions and 8 deletions
+24 -2
View File
@@ -34,6 +34,21 @@ builder.Services.Configure<SmtpOptions>(
// --- Client typé BAN (Base Adresses Nationale) --- // --- Client typé BAN (Base Adresses Nationale) ---
// BaseAddress et Timeout proviennent de BANOptions (appsettings.json). // BaseAddress et Timeout proviennent de BANOptions (appsettings.json).
// Fabrique le handler HTTP des clients sortants (BAN, ORS). Le recyclage des
// connexions du pool évite les blocages intermittents dus à une connexion TLS
// gardée ouverte mais fermée côté serveur/NAT, que .NET tentait de réutiliser :
// - PooledConnectionLifetime : durée de vie max d'une connexion avant recyclage ;
// - PooledConnectionIdleTimeout : fermeture des connexions inactives (avant qu'elles
// ne deviennent « éventées ») ;
// - ConnectTimeout : échec rapide si l'ouverture de connexion traîne (le fallback
// Haversine prend alors le relais proprement au lieu de pendre).
static SocketsHttpHandler CreerHandlerHttpSortant() => new()
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
ConnectTimeout = TimeSpan.FromSeconds(5)
};
var banOptions = builder.Configuration var banOptions = builder.Configuration
.GetSection(BANOptions.SectionName) .GetSection(BANOptions.SectionName)
.Get<BANOptions>() ?? new BANOptions(); .Get<BANOptions>() ?? new BANOptions();
@@ -42,7 +57,11 @@ builder.Services.AddHttpClient<IBANClient, BANClient>(client =>
{ {
client.BaseAddress = new Uri(banOptions.BaseUrl); client.BaseAddress = new Uri(banOptions.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(banOptions.TimeoutSeconds); client.Timeout = TimeSpan.FromSeconds(banOptions.TimeoutSeconds);
}); })
// Recyclage des connexions du pool pour éviter de réutiliser une connexion TLS
// « éventée » (fermée côté serveur/NAT), qui provoquait des blocages intermittents
// jusqu'au timeout. Voir aussi le client ORS ci-dessous.
.ConfigurePrimaryHttpMessageHandler(() => CreerHandlerHttpSortant());
// --- Client typé ORS (OpenRouteService) --- // --- Client typé ORS (OpenRouteService) ---
// BaseAddress, Timeout et en-tête de clé d'API proviennent de ORSOptions (appsettings.json). // BaseAddress, Timeout et en-tête de clé d'API proviennent de ORSOptions (appsettings.json).
@@ -71,7 +90,10 @@ builder.Services.AddHttpClient<IORSClient, ORSClient>(client =>
// telle quelle et éviter une FormatException à la création du client. // telle quelle et éviter une FormatException à la création du client.
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", orsOptions.ApiKey); client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", orsOptions.ApiKey);
} }
}); })
// Même recyclage des connexions que pour BAN (évite les blocages intermittents sur
// une connexion HTTPS réutilisée alors qu'elle a été fermée côté serveur).
.ConfigurePrimaryHttpMessageHandler(() => CreerHandlerHttpSortant());
// --- Calculateur de distance (orchestration BAN + ORS + fallback Haversine) --- // --- Calculateur de distance (orchestration BAN + ORS + fallback Haversine) ---
builder.Services.AddScoped<IDistanceCalculator, DistanceCalculatorService>(); builder.Services.AddScoped<IDistanceCalculator, DistanceCalculatorService>();
@@ -50,15 +50,19 @@ public class ORSClient : IORSClient
{ {
response = await _httpClient.PostAsJsonAsync(DirectionsGeoJsonPath, body, cancellationToken); response = await _httpClient.PostAsJsonAsync(DirectionsGeoJsonPath, body, cancellationToken);
} }
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
// Le HttpClient annule via son Timeout : OperationCanceledException est levée // Annulation DEMANDÉE PAR L'APPELANT (ex. requête HTTP entrante annulée par le
// sans que le jeton de l'appelant soit annulé -> il s'agit d'un dépassement de délai. // client) : on propage l'annulation telle quelle. Ce n'est PAS un timeout ORS,
throw new ORSTimeoutException( // donc on ne la maquille pas en ORSTimeoutException et on ne déclenche pas le
"L'appel à OpenRouteService a dépassé le délai imparti.", ex); // fallback Haversine à tort.
throw;
} }
catch (TaskCanceledException ex) catch (OperationCanceledException ex)
{ {
// Jeton de l'appelant NON déclenché : l'annulation vient du Timeout propre au
// HttpClient ORS -> dépassement de délai réel d'OpenRouteService. (TaskCanceledException
// hérite d'OperationCanceledException : ce bloc couvre les deux.)
throw new ORSTimeoutException( throw new ORSTimeoutException(
"L'appel à OpenRouteService a dépassé le délai imparti.", ex); "L'appel à OpenRouteService a dépassé le délai imparti.", ex);
} }