< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Health.HttpProbe
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/HttpProbe.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
90%
Covered lines: 54
Uncovered lines: 6
Coverable lines: 60
Total lines: 181
Line coverage: 90%
Branch coverage
71%
Covered branches: 23
Total branches: 32
Branch coverage: 71.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/13/2025 - 16:52:37 Line coverage: 90% (54/60) Branch coverage: 71.8% (23/32) Total lines: 181 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 10/13/2025 - 16:52:37 Line coverage: 90% (54/60) Branch coverage: 71.8% (23/32) Total lines: 181 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%44100%
get_Name()100%11100%
get_Tags()100%11100%
get_Logger()100%11100%
CheckAsync()100%1178.57%
ExecuteHttpRequestAsync()83.33%66100%
ParseHealthResponse(...)100%44100%
TryParseHealthContract(...)42.85%151481.25%
HandleNonContractResponse(...)100%44100%
HandleTimeout()100%22100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/HttpProbe.cs

#LineLine coverage
 1using System.Text.Json;
 2using Serilog.Events;
 3
 4namespace Kestrun.Health;
 5
 6/// <summary>
 7/// A health probe that performs an HTTP GET request to a specified URL and interprets the JSON
 8/// response according to the health probe contract.
 9/// </summary>
 10/// <remarks>
 11/// Initializes a new instance of the <see cref="HttpProbe"/> class.
 12/// </remarks>
 13/// <param name="name">The name of the probe.</param>
 14/// <param name="tags">The tags associated with the probe.</param>
 15/// <param name="http">The HTTP client to use.</param>
 16/// <param name="url">The URL to probe.</param>
 17/// <param name="timeout">The timeout for the probe.</param>
 18/// <param name="logger">Optional logger; if null a contextual logger is created.</param>
 519public sealed class HttpProbe(string name, string[] tags, HttpClient http, string url, TimeSpan? timeout = null, Serilog
 20{
 21    /// <summary>
 22    /// The name of the probe.
 23    /// </summary>
 2024    public string Name { get; } = name;
 25    /// <summary>
 26    /// The tags associated with the probe.
 27    /// </summary>
 528    public string[] Tags { get; } = tags;
 29    /// <summary>
 30    /// Logger used for diagnostics.
 31    /// </summary>
 3432    public Serilog.ILogger Logger { get; init; } = logger ?? Serilog.Log.ForContext("HealthProbe", name);
 33    /// <summary>
 34    /// The HTTP client to use.
 35    /// </summary>
 536    private readonly HttpClient _http = http;
 537    private readonly string _url = url;
 538    private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromSeconds(5);
 39
 40    /// <summary>
 41    /// Executes the HTTP GET request and interprets the response according to the health probe contract.
 42    /// </summary>
 43    /// <param name="ct">The cancellation token.</param>
 44    /// <returns>A task representing the asynchronous operation, with a <see cref="ProbeResult"/> as the result.</return
 45    public async Task<ProbeResult> CheckAsync(CancellationToken ct = default)
 46    {
 547        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 548        cts.CancelAfter(_timeout);
 49
 50        try
 51        {
 552            var (response, body) = await ExecuteHttpRequestAsync(cts.Token).ConfigureAwait(false);
 353            return ParseHealthResponse(response, body);
 54        }
 155        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 56        {
 57            // Upstream/request cancellation -> propagate so the runner can handle overall request abort semantics.
 058            throw;
 59        }
 160        catch (TaskCanceledException) when (cts.Token.IsCancellationRequested) // timeout from our internal cts
 61        {
 162            return HandleTimeout();
 63        }
 064        catch (OperationCanceledException) when (cts.Token.IsCancellationRequested) // internal timeout (already handled
 65        {
 066            return HandleTimeout();
 67        }
 168        catch (Exception ex)
 69        {
 170            Logger.Error(ex, "HttpProbe {Probe} failed", Name);
 171            return new ProbeResult(ProbeStatus.Unhealthy, $"Exception: {ex.Message}");
 72        }
 573    }
 74
 75    /// <summary>
 76    /// Executes the HTTP GET request and returns the response and body.
 77    /// </summary>
 78    /// <param name="cancellationToken">The cancellation token.</param>
 79    /// <returns>A tuple containing the HTTP response and response body.</returns>
 80    private async Task<(HttpResponseMessage Response, string? Body)> ExecuteHttpRequestAsync(CancellationToken cancellat
 81    {
 582        if (Logger.IsEnabled(LogEventLevel.Debug))
 83        {
 584            Logger.Debug("HttpProbe {Probe} sending GET {Url} (timeout={Timeout})", Name, _url, _timeout);
 85        }
 86
 587        var response = await _http.GetAsync(_url, cancellationToken).ConfigureAwait(false);
 388        var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 89
 390        if (Logger.IsEnabled(LogEventLevel.Debug))
 91        {
 392            Logger.Debug("HttpProbe {Probe} received {StatusCode} length={Length}", Name, (int)response.StatusCode, body
 93        }
 94
 395        return (response, body);
 396    }
 97
 98    /// <summary>
 99    /// Parses the HTTP response and determines the health status.
 100    /// </summary>
 101    /// <param name="response">The HTTP response.</param>
 102    /// <param name="body">The response body.</param>
 103    /// <returns>The probe result.</returns>
 104    private ProbeResult ParseHealthResponse(HttpResponseMessage response, string? body)
 105    {
 3106        var contractResult = TryParseHealthContract(body);
 3107        if (contractResult != null)
 108        {
 1109            if (Logger.IsEnabled(LogEventLevel.Debug))
 110            {
 1111                Logger.Debug("HttpProbe {Probe} parsed contract status={Status}", Name, contractResult.Status);
 112            }
 1113            return contractResult;
 114        }
 115
 2116        return HandleNonContractResponse(response);
 117    }
 118
 119    /// <summary>
 120    /// Attempts to parse the response body as a health contract JSON.
 121    /// </summary>
 122    /// <param name="body">The response body.</param>
 123    /// <returns>The probe result if parsing succeeds, null otherwise.</returns>
 124    private ProbeResult? TryParseHealthContract(string? body)
 125    {
 126        try
 127        {
 3128            var doc = JsonDocument.Parse(body ?? string.Empty);
 1129            var statusStr = doc.RootElement.GetProperty("status").GetString();
 1130            var status = statusStr?.ToLowerInvariant() switch
 1131            {
 1132                ProbeStatusLabels.STATUS_HEALTHY => ProbeStatus.Healthy,
 0133                ProbeStatusLabels.STATUS_DEGRADED => ProbeStatus.Degraded,
 0134                ProbeStatusLabels.STATUS_UNHEALTHY => ProbeStatus.Unhealthy,
 0135                _ => ProbeStatus.Unhealthy
 1136            };
 1137            var desc = doc.RootElement.TryGetProperty("description", out var d) ? d.GetString() : null;
 1138            return new ProbeResult(status, desc, null);
 139        }
 2140        catch
 141        {
 2142            if (Logger.IsEnabled(LogEventLevel.Debug))
 143            {
 2144                Logger.Debug("HttpProbe {Probe} response body is not valid contract JSON", Name);
 145            }
 2146            return null;
 147        }
 3148    }
 149
 150    /// <summary>
 151    /// Handles responses that don't conform to the health contract.
 152    /// </summary>
 153    /// <param name="response">The HTTP response.</param>
 154    /// <returns>The probe result.</returns>
 155    private ProbeResult HandleNonContractResponse(HttpResponseMessage response)
 156    {
 2157        var result = response.IsSuccessStatusCode
 2158            ? new ProbeResult(ProbeStatus.Degraded, "No contract JSON")
 2159            : new ProbeResult(ProbeStatus.Unhealthy, $"HTTP {(int)response.StatusCode}");
 160
 2161        if (Logger.IsEnabled(LogEventLevel.Debug))
 162        {
 2163            Logger.Debug("HttpProbe {Probe} non-contract response mapped to {Status}", Name, result.Status);
 164        }
 165
 2166        return result;
 167    }
 168
 169    /// <summary>
 170    /// Handles timeout scenarios.
 171    /// </summary>
 172    /// <returns>The probe result for timeout.</returns>
 173    private ProbeResult HandleTimeout()
 174    {
 1175        if (Logger.IsEnabled(LogEventLevel.Debug))
 176        {
 1177            Logger.Debug("HttpProbe {Probe} timed out after {Timeout}", Name, _timeout);
 178        }
 1179        return new ProbeResult(ProbeStatus.Degraded, $"Timeout after {_timeout}");
 180    }
 181}