diff --git a/src/Vantage.Core/Health/HealthChecker.cs b/src/Vantage.Core/Health/HealthChecker.cs new file mode 100644 index 0000000..7f15195 --- /dev/null +++ b/src/Vantage.Core/Health/HealthChecker.cs @@ -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 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 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 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); + } + } +} diff --git a/src/Vantage.Core/Health/IHealthChecker.cs b/src/Vantage.Core/Health/IHealthChecker.cs new file mode 100644 index 0000000..a995732 --- /dev/null +++ b/src/Vantage.Core/Health/IHealthChecker.cs @@ -0,0 +1,8 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Health; + +public interface IHealthChecker +{ + Task CheckAsync(HealthCheckConfig check, CancellationToken cancellationToken); +} diff --git a/src/Vantage.Core/Health/TcpEndpoint.cs b/src/Vantage.Core/Health/TcpEndpoint.cs new file mode 100644 index 0000000..6578592 --- /dev/null +++ b/src/Vantage.Core/Health/TcpEndpoint.cs @@ -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; + } +}