feat(core): add HTTP and TCP health checker
This commit is contained in:
1 parent
b4d46783b3
commit
224424df7a
3 files changed
+122
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user