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.
138 lines
5.0 KiB
C#
138 lines
5.0 KiB
C#
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
|
|
namespace SchotzIadKmTracker.Api.Services;
|
|
|
|
/// <summary>
|
|
/// Client typé (HttpClient) de l'API OpenRouteService (ORS) pour le calcul
|
|
/// d'itinéraire routier. La <c>BaseAddress</c>, le <c>Timeout</c> et l'en-tête
|
|
/// de clé d'API sont configurés lors de l'enregistrement du client typé dans
|
|
/// <c>Program.cs</c> à partir de <c>ORSOptions</c>.
|
|
/// </summary>
|
|
public class ORSClient : IORSClient
|
|
{
|
|
// Point d'accès GeoJSON : retourne une FeatureCollection avec la géométrie
|
|
// de l'itinéraire au format GeoJSON et le résumé (distance en mètres).
|
|
private const string DirectionsGeoJsonPath = "v2/directions/driving-car/geojson";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private readonly HttpClient _httpClient;
|
|
|
|
public ORSClient(HttpClient httpClient)
|
|
{
|
|
_httpClient = httpClient;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RouteResult> CalculateRouteAsync(
|
|
double lat1,
|
|
double lon1,
|
|
double lat2,
|
|
double lon2,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// ORS attend les coordonnées au format [longitude, latitude].
|
|
var body = new
|
|
{
|
|
coordinates = new[]
|
|
{
|
|
new[] { lon1, lat1 },
|
|
new[] { lon2, lat2 }
|
|
}
|
|
};
|
|
|
|
HttpResponseMessage response;
|
|
try
|
|
{
|
|
response = await _httpClient.PostAsJsonAsync(DirectionsGeoJsonPath, body, cancellationToken);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Annulation DEMANDÉE PAR L'APPELANT (ex. requête HTTP entrante annulée par le
|
|
// client) : on propage l'annulation telle quelle. Ce n'est PAS un timeout ORS,
|
|
// donc on ne la maquille pas en ORSTimeoutException et on ne déclenche pas le
|
|
// fallback Haversine à tort.
|
|
throw;
|
|
}
|
|
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(
|
|
"L'appel à OpenRouteService a dépassé le délai imparti.", ex);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
throw new ORSException(
|
|
"Échec de l'appel réseau à OpenRouteService.", ex);
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new ORSException(
|
|
$"OpenRouteService a retourné un statut non conforme : {(int)response.StatusCode}.");
|
|
}
|
|
|
|
try
|
|
{
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
|
return ParseRouteResult(document.RootElement);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
throw new ORSException(
|
|
"La réponse d'OpenRouteService n'a pas pu être analysée.", ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extrait la distance (mètres -> km) et la géométrie GeoJSON de la réponse ORS.
|
|
/// La réponse GeoJSON est une FeatureCollection dont la première feature contient
|
|
/// <c>properties.summary.distance</c> (mètres) et <c>geometry</c> (GeoJSON).
|
|
/// </summary>
|
|
private static RouteResult ParseRouteResult(JsonElement root)
|
|
{
|
|
if (!root.TryGetProperty("features", out var features)
|
|
|| features.ValueKind != JsonValueKind.Array
|
|
|| features.GetArrayLength() == 0)
|
|
{
|
|
throw new ORSException(
|
|
"La réponse d'OpenRouteService ne contient aucun itinéraire.");
|
|
}
|
|
|
|
var feature = features[0];
|
|
|
|
double distanceMeters;
|
|
if (feature.TryGetProperty("properties", out var properties)
|
|
&& properties.TryGetProperty("summary", out var summary)
|
|
&& summary.TryGetProperty("distance", out var distanceElement)
|
|
&& distanceElement.ValueKind == JsonValueKind.Number)
|
|
{
|
|
distanceMeters = distanceElement.GetDouble();
|
|
}
|
|
else
|
|
{
|
|
throw new ORSException(
|
|
"La distance est absente de la réponse d'OpenRouteService.");
|
|
}
|
|
|
|
// Conversion mètres -> km, arrondi à 2 décimales.
|
|
var distanceKm = Math.Round((decimal)(distanceMeters / 1000.0), 2, MidpointRounding.AwayFromZero);
|
|
|
|
string? geometrieJson = null;
|
|
if (feature.TryGetProperty("geometry", out var geometry)
|
|
&& geometry.ValueKind == JsonValueKind.Object)
|
|
{
|
|
geometrieJson = geometry.GetRawText();
|
|
}
|
|
|
|
return new RouteResult(distanceKm, geometrieJson);
|
|
}
|
|
}
|