Files
MoShiro/backend/shared/MoShiro.Measurement/Blocks/Http.cs
T

384 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Http;
using System.Net.Quic;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>http 请求的结果,编码与 Backend 的 <c>HttpOutcome</c> 一致。</summary>
internal static class HttpOutcomes
{
public const int Ok = 0;
public const int Timeout = 1;
public const int Reset = 2;
public const int Tls = 3;
public const int Other = 4;
public const int Dns = 5;
public const int BadStatus = 6;
public const int Unsupported = 7;
public const int NotAllowed = 8;
}
/// <summary>解析出的地址都不在本地允许的网段内。</summary>
internal sealed class TargetNotAllowedException : Exception
{
public TargetNotAllowedException(string host)
: base("no address of " + host + " is allowed by allowed_target_cidrs")
{
}
}
/// <summary>
/// 单个 Agent 去请求一个 URL(HTTP/1.1、HTTP/2 或 HTTP/3,不回退),记录 DNS、TCP 建连、首字节、总耗时和状态码。
/// 每次请求新建连接,所以每次都包含完整的建连过程;频率按失败率自适应:目标一直应答就降到证明得了成功率的最低频率,
/// 出现失败升到最高频率,全部失败时指数退避。
/// </summary>
public sealed class HttpDialer : BlockBase
{
private readonly DriverLog _log;
private readonly int _attempt;
private readonly int _ts;
private readonly int _w;
private readonly int _dns;
private readonly int _tcp;
private readonly int _ttfb;
private readonly int _total;
private readonly int _status;
private readonly int _proto;
private readonly int _outcome;
private readonly HashSet<int> _loggedOutcomes = [];
private long _next;
public HttpDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("req")!, Side.Dialer, 1024);
_attempt = _log.Store.ColumnIndex("attempt");
_ts = _log.Store.ColumnIndex("ts");
_w = _log.Store.ColumnIndex("w");
_dns = _log.Store.ColumnIndex("dns_us");
_tcp = _log.Store.ColumnIndex("tcp_us");
_ttfb = _log.Store.ColumnIndex("ttfb_us");
_total = _log.Store.ColumnIndex("total_us");
_status = _log.Store.ColumnIndex("status");
_proto = _log.Store.ColumnIndex("proto");
_outcome = _log.Store.ColumnIndex("outcome");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
if (!TargetHost.TryParse(Env.Target, out _, out _, out Uri? url) || url is null)
{
Env.Log.Warn("http target is not a URL", ("task", Spec.Scope.Task?.Value));
await WaitUntilCancelledAsync(ct);
return;
}
AdaptiveRate? rate = CreateRequestRate();
try
{
if (rate is not null)
{
await PaceAsync(rate, async () =>
{
int weight = rate.Weight;
long sent = Env.Clock.NowUs();
rate.OnSend(sent);
bool ok = await RequestAsync(url, weight, ct);
if (ok)
{
long now = Env.Clock.NowUs();
rate.OnReply(sent, now, (now - sent) / 1000.0);
}
}, ct);
}
else
{
TimeSpan interval = TimeSpan.FromSeconds(60.0 / Spec.Int("per_minute"));
while (!ct.IsCancellationRequested)
{
long start = Env.Clock.NowUs();
await RequestAsync(url, 1, ct);
TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - start);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, Env.Time, ct);
}
}
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "ts");
return 1;
}
/// <summary>
/// 频率以「次/分钟」声明,换算成 Hz 交给 <see cref="AdaptiveRate"/>。请求间隔以秒到分钟计,所以窗口按分钟级放宽,
/// 并且不用 RTT 分位数的精度保护(样本太少,估计不出来):平稳时只由成功率的上界决定频率。
/// </summary>
private AdaptiveRate? CreateRequestRate()
{
if (!Spec.Bool("adaptive"))
{
return null;
}
double max = Math.Max(Spec.Int("rate_max_per_min"), Spec.Int("rate_min_per_min")) / 60.0;
ReportStart(max);
return new AdaptiveRate(new AdaptiveRateOptions
{
MinHz = Spec.Int("rate_min_per_min") / 60.0,
MaxHz = max,
TauPct = Spec.Number("tau_pct"),
RttGuard = false,
CertifySeconds = 900,
QuietCertifySeconds = 1200,
GraceSeconds = Math.Ceiling(Spec.Int("timeout_ms") / 1000.0) + 1,
DeadWindowSeconds = 120,
DeadMinSent = 3,
DeadMaxIntervalSeconds = 300,
ActiveLossCount = 2,
Prior = Env.LossPrior,
});
}
/// <summary>发一次请求并记一行;返回这次算不算成功(有应答且状态码合格)。</summary>
private async Task<bool> RequestAsync(Uri url, int weight, CancellationToken ct)
{
string version = Spec.Text("version");
bool verify = Spec.Bool("tls_verify");
TimeSpan timeout = TimeSpan.FromMilliseconds(Spec.Int("timeout_ms"));
var phases = new Phases();
long start = Env.Clock.NowUs();
int outcome = HttpOutcomes.Other;
int status = 0;
int proto = 0;
long? ttfb = null;
long? total = null;
using var timeoutCts = new CancellationTokenSource(timeout, Env.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
if (version == "h3" && !QuicConnection.IsSupported)
{
outcome = HttpOutcomes.Unsupported;
LogFirstFailure(outcome, url, version, null);
}
else
{
if (version == "h3")
{
await ResolveForQuicAsync(url.IdnHost, phases, linked.Token);
}
using HttpMessageInvoker invoker = CreateInvoker(version, verify, phases);
using var request = new HttpRequestMessage(new HttpMethod(Spec.Text("method")), url)
{
Version = version switch { "h1" => HttpVersion.Version11, "h2" => HttpVersion.Version20, _ => HttpVersion.Version30 },
VersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using HttpResponseMessage response = await invoker.SendAsync(request, linked.Token);
ttfb = Env.Clock.NowUs() - start;
status = (int)response.StatusCode;
proto = response.Version.Major switch { 3 => 3, 2 => 2, _ => 1 };
await ReadBodyAsync(response, (int)Spec.Int("read_kb") * 1024, linked.Token);
total = Env.Clock.NowUs() - start;
long expected = Spec.Int("ok_status");
outcome = (expected == 0 ? status < 400 : status == expected) ? HttpOutcomes.Ok : HttpOutcomes.BadStatus;
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
outcome = HttpOutcomes.Timeout;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or SocketException or AuthenticationException or TargetNotAllowedException or QuicException)
{
outcome = Classify(ex);
LogFirstFailure(outcome, url, version, ex);
}
Record(start, weight, phases, ttfb, total, status, proto, outcome);
return outcome == HttpOutcomes.Ok;
}
private HttpMessageInvoker CreateInvoker(string version, bool verify, Phases phases)
{
var handler = new SocketsHttpHandler
{
UseProxy = false,
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
};
// 设置了 ConnectCallback 的处理器不能用 HTTP/3(运行时用自己的 QUIC 连接,不经过回调):h3 只能靠请求前的解析和网段检查
if (version != "h3")
{
handler.ConnectCallback = (context, token) => ConnectAsync(context.DnsEndPoint, phases, token);
}
if (!verify)
{
#pragma warning disable CA5359 // 操作员明确关闭证书校验(tls_verify=false):只测可达性和耗时
handler.SslOptions = new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true };
#pragma warning restore CA5359
}
return new HttpMessageInvoker(handler, disposeHandler: true);
}
/// <summary>自己解析和连接:每个地址都要在允许的网段里,DNS 和 TCP 建连的耗时分别记下来。</summary>
private async ValueTask<Stream> ConnectAsync(DnsEndPoint endpoint, Phases phases, CancellationToken ct)
{
IPAddress[] addresses = await ResolveAsync(endpoint.Host, phases, ct);
long start = Env.Clock.NowUs();
Exception? last = null;
foreach (IPAddress address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(new IPEndPoint(address, endpoint.Port), ct);
phases.TcpUs = Env.Clock.NowUs() - start;
return new NetworkStream(socket, ownsSocket: true);
}
catch (SocketException ex)
{
socket.Dispose();
last = ex;
}
}
throw last ?? new SocketException((int)SocketError.HostNotFound);
}
/// <summary>HTTP/3 由运行时自己建 UDP 连接,没法接管;先解析并检查一遍,再让它去连。</summary>
private async Task ResolveForQuicAsync(string host, Phases phases, CancellationToken ct) => _ = await ResolveAsync(host, phases, ct);
private async Task<IPAddress[]> ResolveAsync(string host, Phases phases, CancellationToken ct)
{
long start = Env.Clock.NowUs();
IPAddress[] all = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
phases.DnsUs = Env.Clock.NowUs() - start;
IPAddress[] allowed = [.. all.Where(a => Env.AllowAddress?.Invoke(a) ?? true)];
return allowed.Length > 0 ? allowed : throw new TargetNotAllowedException(host);
}
private static async Task ReadBodyAsync(HttpResponseMessage response, int limit, CancellationToken ct)
{
if (limit <= 0)
{
return;
}
await using Stream body = await response.Content.ReadAsStreamAsync(ct);
byte[] buffer = new byte[Math.Min(limit, 16 * 1024)];
int read = 0;
while (read < limit)
{
int n = await body.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, limit - read)), ct);
if (n == 0)
{
break;
}
read += n;
}
}
/// <summary>每种失败结果只记第一次(带原因),保留给事后审计,不会随请求频率刷屏。</summary>
private void LogFirstFailure(int outcome, Uri url, string version, Exception? ex)
{
if (!_loggedOutcomes.Add(outcome))
{
return;
}
Env.Log.Warn("http request failed", ("task", Spec.Scope.Task?.Value), ("outcome", outcome), ("version", version), ("host", url.Host),
("quic_supported", version == "h3" ? QuicConnection.IsSupported : null), ("err", ex is null ? "System.Net.Quic is not available" : ex.GetType().Name + ": " + (ex.InnerException?.Message ?? ex.Message)));
}
private static int Classify(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
switch (e)
{
case TargetNotAllowedException:
return HttpOutcomes.NotAllowed;
case AuthenticationException:
return HttpOutcomes.Tls;
case SocketException { SocketErrorCode: SocketError.HostNotFound or SocketError.TryAgain or SocketError.NoData }:
return HttpOutcomes.Dns;
case SocketException { SocketErrorCode: SocketError.ConnectionRefused or SocketError.ConnectionReset }:
return HttpOutcomes.Reset;
case HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError }:
return HttpOutcomes.Dns;
case HttpRequestException { HttpRequestError: HttpRequestError.SecureConnectionError }:
return HttpOutcomes.Tls;
case HttpRequestException { HttpRequestError: HttpRequestError.VersionNegotiationError }:
return HttpOutcomes.Unsupported;
}
}
return HttpOutcomes.Other;
}
private void Record(long startUs, int weight, Phases phases, long? ttfb, long? total, int status, int proto, int outcome)
{
long key = _log.Store.Append();
_log.Store.Set(key, _attempt, _next++);
_log.Store.Set(key, _ts, startUs);
_log.Store.Set(key, _w, Math.Clamp(weight, 1, 65535));
_log.Store.Set(key, _outcome, outcome);
SetIf(key, _dns, phases.DnsUs);
SetIf(key, _tcp, phases.TcpUs);
SetIf(key, _ttfb, ttfb);
SetIf(key, _total, total);
if (status != 0)
{
_log.Store.Set(key, _status, status);
_log.Store.Set(key, _proto, proto);
}
}
private void SetIf(long key, int column, long? value)
{
if (value is { } v)
{
_log.Store.Set(key, column, Math.Clamp(v, 0, uint.MaxValue));
}
}
/// <summary>一次请求里 DNS 和 TCP 建连用了多久(连接回调里填)。</summary>
private sealed class Phases
{
public long? DnsUs { get; set; }
public long? TcpUs { get; set; }
}
}