Private/Invoke-AACEndpointProbe.ps1

function Invoke-AACEndpointProbe {
    <#
    .SYNOPSIS
        Probes endpoints - HTTP(S) requests and TCP connections - all at
        once, -Round times, and returns each attempt's outcome and latency.
    .DESCRIPTION
        -Probe: @{ Key; Kind = 'Http' or 'Tcp'; Uri (Http); Host and Port
        (Tcp); Method ('GET'); ExpectedStatus (an int[], or 'Below500' - up
        unless the server errs); Contains (text the body must have);
        TimeoutSeconds (10) }.
 
        HTTP requests go through an HttpClient of their own - never the one
        that carries the Azure token, so no credential reaches the endpoint -
        with a user agent of Azure.Admin.Console-HealthCheck. Each attempt
        is timed from the start of its round to when it completes. Rounds
        are -IntervalSeconds apart.
 
        Returns a hashtable: Key -> @( @{ Ok; Status (the HTTP status, or 0);
        LatencyMs; Error } per round ).
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Probe,

        [ValidateRange(1, 100)]
        [int] $Round = 1,

        [ValidateRange(0, 3600)]
        [int] $IntervalSeconds = 5,

        [scriptblock] $OnRound
    )

    $results = @{}
    foreach ($p in $Probe) { $results[[string]$p.Key] = [System.Collections.Generic.List[object]]::new() }
    if (-not $Probe.Count) { return $results }
    $handler = [System.Net.Http.HttpClientHandler]::new()
    $handler.AllowAutoRedirect = $true
    $client = [System.Net.Http.HttpClient]::new($handler)
    $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
    $client.DefaultRequestHeaders.UserAgent.ParseAdd('Azure.Admin.Console-HealthCheck/1.0')
    try {
        for ($r = 1; $r -le $Round; $r++) {
            if ($r -gt 1 -and $IntervalSeconds) { Start-Sleep -Seconds $IntervalSeconds }
            $clock = [System.Diagnostics.Stopwatch]::StartNew()
            $pending = [System.Collections.Generic.List[object]]::new()
            foreach ($p in $Probe) {
                $timeout = if ($p.Contains('TimeoutSeconds') -and $p.TimeoutSeconds) { [int]$p.TimeoutSeconds } else { 10 }
                $cancel = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds($timeout))
                try {
                    if ($p.Kind -eq 'Tcp') {
                        $tcp = [System.Net.Sockets.TcpClient]::new()
                        $task = $tcp.ConnectAsync([string]$p.Host, [int]$p.Port, $cancel.Token).AsTask()
                        $pending.Add(@{ Probe = $p; Task = $task; Tcp = $tcp; Cancel = $cancel })
                    }
                    else {
                        $message = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($(if ($p.Contains('Method') -and $p.Method) { [string]$p.Method } else { 'GET' })), [string]$p.Uri)
                        $task = $client.SendAsync($message, [System.Net.Http.HttpCompletionOption]::ResponseContentRead, $cancel.Token)
                        $pending.Add(@{ Probe = $p; Task = $task; Cancel = $cancel })
                    }
                }
                catch { $results[[string]$p.Key].Add(@{ Ok = $false; Status = 0; LatencyMs = 0; Error = $_.Exception.Message }) ; $cancel.Dispose() }
            }
            while ($pending.Count) {
                $index = [System.Threading.Tasks.Task]::WaitAny([System.Threading.Tasks.Task[]]@($pending | ForEach-Object { $_.Task }), 1000)
                if ($index -lt 0) { continue }
                $done = $pending[$index]
                $pending.RemoveAt($index)
                $elapsed = [Math]::Round($clock.Elapsed.TotalMilliseconds)
                $p = $done.Probe
                $outcome = @{ Ok = $false; Status = 0; LatencyMs = $elapsed; Error = '' }
                if ($done.Task.IsFaulted -or $done.Task.IsCanceled) {
                    $outcome.Error = if ($done.Task.IsCanceled) { "no answer within $(if ($p.Contains('TimeoutSeconds') -and $p.TimeoutSeconds) { $p.TimeoutSeconds } else { 10 }) seconds" } else { $done.Task.Exception.GetBaseException().Message }
                }
                elseif ($p.Kind -eq 'Tcp') { $outcome.Ok = $true }
                else {
                    $response = $done.Task.Result
                    $outcome.Status = [int]$response.StatusCode
                    $expected = if ($p.Contains('ExpectedStatus') -and $p.ExpectedStatus) { $p.ExpectedStatus } else { @(200..399) }
                    $statusOk = if ($expected -eq 'Below500') { $outcome.Status -lt 500 } else { @($expected) -contains $outcome.Status }
                    $contentOk = $true
                    if ($p.Contains('Contains') -and $p.Contains) {
                        $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
                        $contentOk = $body.Contains([string]$p.Contains)
                        if (-not $contentOk) { $outcome.Error = "the response doesn't contain '$($p.Contains)'" }
                    }
                    if (-not $statusOk) { $outcome.Error = "HTTP $($outcome.Status) $($response.ReasonPhrase)" }
                    $outcome.Ok = $statusOk -and $contentOk
                    $response.Dispose()
                }
                if ($done.Contains('Tcp')) { $done.Tcp.Dispose() }
                $done.Cancel.Dispose()
                $results[[string]$p.Key].Add($outcome)
            }
            if ($OnRound) { & $OnRound $r $Round }
        }
    }
    finally { $client.Dispose() }
    $results
}