feat(core): add HTTP and TCP health checker

This commit is contained in:
dualfroz committed 2026-08-24 12:13:39 +02:00
1 parent b4d46783b3
commit 224424df7a
3 files changed
+122

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
using System.Diagnostics;
using System.Net.Sockets;
using Vantage.Core.Models;
namespace Vantage.Core.Health;
public sealed class HealthChecker : IHealthChecker
{
private readonly HttpClient httpClient;
public HealthChecker(HttpClient httpClient)
{
this.httpClient = httpClient;
}
public Task<HealthCheckResult> CheckAsync(HealthCheckConfig check, CancellationToken cancellationToken) =>
check.Kind switch
{
HealthKind.Http => CheckHttpAsync(check, cancellationToken),
HealthKind.Tcp => CheckTcpAsync(check, cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(check))
};
private async Task<HealthCheckResult> CheckHttpAsync(HealthCheckConfig check, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(check.TimeoutMs);
var stopwatch = Stopwatch.StartNew();
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, check.Target);
using var response = await httpClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
.ConfigureAwait(false);
stopwatch.Stop();
var code = (int)response.StatusCode;
var latency = stopwatch.Elapsed.TotalMilliseconds;
var label = $"HTTP {code} {response.ReasonPhrase}".TrimEnd();
if (code >= check.ExpectedStatusMin && code <= check.ExpectedStatusMax)
return HealthCheckResult.Up(latency, label);
return HealthCheckResult.Degraded(latency, $"{label} outside {check.ExpectedStatusMin}-{check.ExpectedStatusMax}");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return HealthCheckResult.Down($"Timed out after {check.TimeoutMs} ms");
}
catch (HttpRequestException ex)
{
return HealthCheckResult.Down(ex.Message);
}
}
private static async Task<HealthCheckResult> CheckTcpAsync(HealthCheckConfig check, CancellationToken cancellationToken)
{
var endpoint = TcpEndpoint.Parse(check.Target);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(check.TimeoutMs);
var stopwatch = Stopwatch.StartNew();
try
{
using var client = new TcpClient();
await client.ConnectAsync(endpoint.Host, endpoint.Port, timeout.Token).ConfigureAwait(false);
stopwatch.Stop();
return HealthCheckResult.Up(stopwatch.Elapsed.TotalMilliseconds, $"Connected to {endpoint.Host}:{endpoint.Port}");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return HealthCheckResult.Down($"Timed out after {check.TimeoutMs} ms");
}
catch (SocketException ex)
{
return HealthCheckResult.Down(ex.Message);
}
}
}
@@ -0,0 +1,8 @@
using Vantage.Core.Models;
namespace Vantage.Core.Health;
public interface IHealthChecker
{
Task<HealthCheckResult> CheckAsync(HealthCheckConfig check, CancellationToken cancellationToken);
}
+33
View File
@@ -0,0 +1,33 @@
namespace Vantage.Core.Health;
public readonly record struct TcpEndpoint(string Host, int Port)
{
public static TcpEndpoint Parse(string target)
{
var trimmed = target.Trim();
if (trimmed.StartsWith('['))
{
var close = trimmed.IndexOf(']');
if (close < 0)
throw new FormatException($"Malformed IPv6 endpoint: '{target}'");
var host = trimmed[1..close];
var rest = trimmed[(close + 1)..].TrimStart(':');
return new TcpEndpoint(host, ParsePort(rest, target));
}
var lastColon = trimmed.LastIndexOf(':');
if (lastColon <= 0 || lastColon == trimmed.Length - 1)
throw new FormatException($"TCP target must be host:port, got '{target}'");
return new TcpEndpoint(trimmed[..lastColon], ParsePort(trimmed[(lastColon + 1)..], target));
}
private static int ParsePort(string value, string original)
{
if (!int.TryParse(value, out var port) || port is < 1 or > 65535)
throw new FormatException($"Invalid port in TCP target '{original}'");
return port;
}
}