Private/Http.ps1

# HTTP. Invoke-VMetricHttp is the transport (one exchange, never throws for an HTTP status); tests replace it.
# Invoke-VMetricRequest is what everything else calls: it adds the Bearer token, refreshes it ahead of expiry
# and once on a 401, waits out another deployment (409 DEPLOYMENT_IN_PROGRESS), retries a GET the proxy
# dropped, refuses a redirect, and turns an error answer into an ErrorRecord with the API's code, message and
# trace id.
#
# The transport is a module-owned System.Net.Http.HttpClient, not Invoke-WebRequest, for two reasons:
# - a failed Invoke-WebRequest leaves its own ErrorRecord in $Error and in -ErrorVariable, whose target is the
# request message, Authorization header included, so Get-Error printed the bearer token. An HttpClient
# failure is an exception that carries no request.
# - Invoke-WebRequest follows redirects, and .NET replays the body of a 307 or 308: a refresh token, a device
# code, an authorization code with its verifier, or a secret, sent to wherever the Location points. The
# client never follows one; Invoke-VMetricRequest reports it instead.

function Get-VMetricUserAgent {
    # VirtualMetric-PowerShell/<module version> PowerShell/<version> <OS>
    [CmdletBinding()]
    [OutputType([string])]
    param()

    if (-not $script:VMetricUserAgent) {
        $os = [regex]::Replace([string][System.Runtime.InteropServices.RuntimeInformation]::OSDescription, '[^\x20-\x7E]', '')
        $os = [regex]::Replace($os, '\s+', ' ').Trim()
        if ($os.Length -gt 100) {
            $os = $os.Substring(0, 100).TrimEnd()
        }
        $script:VMetricUserAgent = 'VirtualMetric-PowerShell/{0} PowerShell/{1} {2}' -f $script:ModuleVersion, $PSVersionTable.PSVersion, $os
    }
    $script:VMetricUserAgent
}

function Wait-VMetricInterval {
    # Start-Sleep, behind a seam so tests do not wait.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [double] $Seconds
    )

    if ($Seconds -gt 0) {
        Start-Sleep -Milliseconds ([int][Math]::Ceiling($Seconds * 1000))
    }
}

function Get-VMetricHttpClient {
    # The module's HttpClient for a certificate policy, made on first use and disposed with the module: it
    # follows no redirect, keeps no cookie, decompresses answers, and uses the system's proxy as
    # Invoke-WebRequest does. The request's own timeout applies, not the client's.
    [CmdletBinding()]
    [OutputType([System.Net.Http.HttpClient])]
    param(
        [switch] $SkipCertificateCheck
    )

    $key = if ($SkipCertificateCheck) { 'SkipCertificateCheck' } else { 'Default' }
    $client = $script:VMetricHttpClients[$key]
    if ($client) {
        return $client
    }
    $handler = [System.Net.Http.HttpClientHandler]::new()
    $handler.AllowAutoRedirect = $false
    $handler.UseCookies = $false
    $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::All
    if ($SkipCertificateCheck) {
        # A compiled delegate: a script block would run on a thread with no runspace.
        $handler.ServerCertificateCustomValidationCallback = [System.Net.Http.HttpClientHandler]::DangerousAcceptAnyServerCertificateValidator
    }
    $client = [System.Net.Http.HttpClient]::new($handler, $true)
    $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
    $script:VMetricHttpClients[$key] = $client
    $client
}

function Invoke-VMetricHttp {
    # One HTTP exchange. The answer is {StatusCode, Content, Headers} for every status, a redirect included;
    # only a failure to get an answer at all (DNS, TLS, a refused connection, a timeout) throws, as an
    # exception that carries no part of the request. The body text may hold a secret: it is never logged.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string] $Method,

        [Parameter(Mandatory)]
        [string] $Uri,

        [hashtable] $Headers = @{},

        [AllowNull()]
        [string] $Body,

        [switch] $SkipCertificateCheck,

        [int] $TimeoutSec = 120
    )

    $client = Get-VMetricHttpClient -SkipCertificateCheck:$SkipCertificateCheck
    $cancel = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds([Math]::Max(1, $TimeoutSec)))
    $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method.ToUpperInvariant()), $Uri)
    $response = $null
    try {
        $null = $request.Headers.TryAddWithoutValidation('User-Agent', (Get-VMetricUserAgent))
        foreach ($name in $Headers.Keys) {
            $null = $request.Headers.TryAddWithoutValidation([string]$name, [string]$Headers[$name])
        }
        if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
            $content = [System.Net.Http.ByteArrayContent]::new([System.Text.Encoding]::UTF8.GetBytes($Body))
            $content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/json; charset=utf-8')
            $request.Content = $content
        }

        $task = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseContentRead, $cancel.Token)
        # Waits in short slices so Ctrl+C stops the wait; the finally block then cancels the request.
        while (-not ([System.IAsyncResult]$task).AsyncWaitHandle.WaitOne(200)) {
        }
        if ($task.IsCanceled) {
            throw [System.TimeoutException]::new("No answer within $TimeoutSec seconds.")
        }
        if ($task.IsFaulted) {
            $failure = $task.Exception.GetBaseException()
            if ($failure -is [System.OperationCanceledException]) {
                throw [System.TimeoutException]::new("No answer within $TimeoutSec seconds.")
            }
            throw $failure
        }
        $response = $task.Result
        # ResponseContentRead: the body is already buffered.
        $bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
        $answerHeaders = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($header in $response.Headers) {
            $answerHeaders[$header.Key] = $header.Value -join ', '
        }
        foreach ($header in $response.Content.Headers) {
            $answerHeaders[$header.Key] = $header.Value -join ', '
        }
        [pscustomobject]@{
            StatusCode = [int]$response.StatusCode
            Content    = [System.Text.Encoding]::UTF8.GetString($bytes)
            Headers    = $answerHeaders
        }
    }
    finally {
        $cancel.Cancel()
        if ($response) {
            $response.Dispose()
        }
        $request.Dispose()
        $cancel.Dispose()
    }
}

function Get-VMetricHeader {
    # A header of a transport answer by name, ignoring case; $null when it has none.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object] $Response,

        [Parameter(Mandatory)]
        [string] $Name
    )

    $headers = $Response.Headers
    if ($headers -isnot [System.Collections.IDictionary]) {
        return $null
    }
    foreach ($key in $headers.Keys) {
        if ([string]::Equals([string]$key, $Name, [System.StringComparison]::OrdinalIgnoreCase)) {
            return [string]$headers[$key]
        }
    }
    return $null
}

function New-VMetricRedirectException {
    # A redirect, refused: following it would send the request, its body and its token included, to whatever
    # address the Location names. The message names that address's origin (the server's text, cleaned), never
    # more of it.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an exception object; changes no state.')]
    [CmdletBinding()]
    [OutputType([System.Exception])]
    param(
        [Parameter(Mandatory)]
        [string] $Uri,

        [Parameter(Mandatory)]
        [int] $StatusCode,

        [AllowNull()]
        [AllowEmptyString()]
        [string] $Location
    )

    $from = ([uri]$Uri).GetLeftPart([System.UriPartial]::Authority)
    $to = $null
    $parsed = $null
    if ($Location -and [System.Uri]::TryCreate([uri]$Uri, $Location.Trim(), [ref] $parsed) -and $parsed.IsAbsoluteUri -and $parsed.Host) {
        $to = ConvertTo-VMetricSafeText -Text $parsed.GetLeftPart([System.UriPartial]::Authority)
    }
    if (-not $to) {
        $message = "$from answered $StatusCode, a redirect without a usable address. The module follows no redirect; connect to the address that serves the VirtualMetric API."
    }
    elseif ($to -eq $from) {
        $message = "$from answered $StatusCode, a redirect to another path on the same server. The module follows no redirect, which would carry the request and its token or secrets elsewhere; check the address given to Connect-VMetric."
    }
    else {
        $message = "$from answered $StatusCode, a redirect to $to. The module follows no redirect, which would carry the request and its token or secrets to another address; if $to serves your VirtualMetric API, connect to it instead."
    }
    New-VMetricException -Message $message -Code 'Redirected' -Category ConnectionError -StatusCode $StatusCode
}

function ConvertTo-VMetricQueryValue {
    # A query value as text: a switch or boolean true/false, a DateTime Unix seconds (the API's timestamps),
    # a list comma-separated. $null leaves the parameter out.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [object] $Value
    )

    if ($null -eq $Value) {
        return $null
    }
    if ($Value -is [System.Security.SecureString]) {
        throw (New-VMetricException -Message 'A secret cannot be sent in a query string.' -Code 'SecretInQuery' -Category InvalidArgument)
    }
    if ($Value -is [System.Management.Automation.SwitchParameter]) {
        return $(if ($Value.IsPresent) { 'true' } else { 'false' })
    }
    if ($Value -is [bool]) {
        return $(if ($Value) { 'true' } else { 'false' })
    }
    if ($Value -is [datetime]) {
        return [string][System.DateTimeOffset]::new($Value.ToUniversalTime()).ToUnixTimeSeconds()
    }
    if ($Value -is [System.DateTimeOffset]) {
        return [string]$Value.ToUnixTimeSeconds()
    }
    if ($Value -is [string]) {
        return $Value.ToString()
    }
    if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [System.Collections.IDictionary]) {
        $parts = foreach ($item in $Value) {
            ConvertTo-VMetricQueryValue -Value $item
        }
        return (@($parts) -join ',')
    }
    return [string]$Value
}

function New-VMetricRequestUri {
    # <API base>v1<Path>?<query>: https://api.example.com/api/v1/devices?pageNumber=1. The base is the API's
    # URL (ending in /api/), or an origin, which serves the API under /api/.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a string; changes no state.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [Alias('Origin')]
        [string] $ApiUrl,

        [Parameter(Mandatory)]
        [string] $Path,

        [System.Collections.IDictionary] $Query
    )

    $builder = [System.Text.StringBuilder]::new()
    [void]$builder.Append((ConvertTo-VMetricApiBase -Url $ApiUrl)).Append('v1')
    if (-not $Path.StartsWith('/')) {
        [void]$builder.Append('/')
    }
    [void]$builder.Append($Path)
    $separator = if ($Path.Contains('?')) { '&' } else { '?' }
    if ($Query) {
        foreach ($key in $Query.Keys) {
            $text = ConvertTo-VMetricQueryValue -Value $Query[$key]
            if ($null -eq $text) {
                continue
            }
            [void]$builder.Append($separator).Append([System.Uri]::EscapeDataString([string]$key)).Append('=').Append([System.Uri]::EscapeDataString($text))
            $separator = '&'
        }
    }
    $builder.ToString()
}

function Test-VMetricAccessTokenExpiring {
    # True when a session that can refresh has under a minute left on its access token.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        [int] $WithinSeconds = 60
    )

    if (-not $Session.RefreshToken -or -not $Session.AccessExpiresAt) {
        return $false
    }
    return ((([System.DateTimeOffset]$Session.AccessExpiresAt) - [System.DateTimeOffset]::UtcNow).TotalSeconds -lt $WithinSeconds)
}

$script:VMetricRefreshRetrySeconds = 25

function Test-VMetricAnswerLost {
    # Whether a failed request may have been served with its answer lost on the way back: no answer at all
    # (a reset connection, a timeout), or a 502, 503 or 504, a proxy's as a rule. Not a TLS failure (the
    # request never got through) and not any other answer.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()]
        [System.Exception] $Exception
    )

    if ($Exception -isnot [VMetricException]) {
        return $false
    }
    if ($Exception.Code -eq 'ConnectionFailed') {
        return ($Exception.Category -ne [System.Management.Automation.ErrorCategory]::SecurityError)
    }
    return ($Exception.StatusCode -in @(502, 503, 504))
}

function Update-VMetricAccessToken {
    # Refreshes a session's access token with its refresh token (which rotates). Holds the session's Lock for
    # the whole exchange: a caller that waited finds the Generation moved on and uses the token another caller
    # just got, instead of sending the refresh token that token replaced (the server would revoke the session).
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Refreshes the in-memory session only.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session,

        # Refresh even if the token is not about to expire (after a 401).
        [switch] $Force,

        # Refresh when the token has less than this left (re-checked under the Lock).
        [int] $WithinSeconds = 60,

        # The Generation the failed request used: no refresh when another caller has refreshed since.
        [int] $UsedGeneration = -1
    )

    if ($Session.IsJobSnapshot) {
        throw (New-VMetricException -Message 'A job never refreshes the session it was given.' -Code 'JobTokenExpired' -Category AuthenticationError -Terminating)
    }
    [System.Threading.Monitor]::Enter($Session.Lock)
    try {
        if ($UsedGeneration -ge 0 -and [int]$Session.Generation -ne $UsedGeneration) {
            return
        }
        if (-not $Force -and -not (Test-VMetricAccessTokenExpiring -Session $Session -WithinSeconds $WithinSeconds)) {
            return
        }
        if (-not $Session.RefreshToken) {
            throw (New-VMetricException -Message 'Your session has ended. Run Connect-VMetric to sign in again.' -Code 'SessionExpired' `
                    -Category AuthenticationError -RecommendedAction 'Run Connect-VMetric to sign in again.' -Terminating)
        }

        Write-Verbose 'Refreshing the access token.'
        # A refresh whose answer was lost (a timeout, a reset connection, a proxy's 502 to 504) may have been
        # served: the server rotated the token and the answer never came. The server takes the token it just
        # rotated once more for 30 seconds and answers the same successor, so the refresh is tried again at
        # once, with the same token, within 25 seconds in all. Waiting for the next command instead would
        # present the spent token after the window, and the server would revoke the session as a replay.
        $deadline = [datetime]::UtcNow.AddSeconds($script:VMetricRefreshRetrySeconds)
        $delays = @(1, 2, 4, 8)
        $attempt = 0
        while ($true) {
            $remaining = ($deadline - [datetime]::UtcNow).TotalSeconds
            try {
                $token = Invoke-VMetricRequest -Method POST -Path '/cli/token' -Anonymous -ApiUrl $Session.ApiUrl `
                    -SkipCertificateCheck:([bool]$Session.SkipCertificateCheck) -NoConflictRetry `
                    -TimeoutSec ([int][Math]::Max(1, [Math]::Min(10, [Math]::Floor($remaining)))) `
                    -Body ([ordered]@{ grantType = 'refresh_token'; refreshToken = $Session.RefreshToken })
                break
            }
            catch {
                if (Test-VMetricFlowControl -ErrorRecord $_) {
                    throw
                }
                $inner = $_.Exception
                if ($attempt -lt $delays.Count -and (Test-VMetricAnswerLost -Exception $inner) -and
                    [datetime]::UtcNow.AddSeconds($delays[$attempt]) -lt $deadline) {
                    Write-Verbose "The refresh got no answer ($($inner.Message)); trying again in $($delays[$attempt]) s with the same token."
                    Wait-VMetricInterval -Seconds $delays[$attempt]
                    $attempt++
                    continue
                }
                throw (New-VMetricException -Message "Your session could not be refreshed: $($inner.Message) Run Connect-VMetric to sign in again." `
                        -Code 'SessionExpired' -Category AuthenticationError -InnerException $inner `
                        -RecommendedAction 'Run Connect-VMetric to sign in again.' -Terminating)
            }
        }
        Update-VMetricSessionToken -Session $Session -TokenResponse $token
    }
    finally {
        [System.Threading.Monitor]::Exit($Session.Lock)
    }
}

function Get-VMetricBearerToken {
    # A session's access token as text, with the Generation it belongs to, read under the session's Lock: a
    # refresh on another thread (a job reads its parent's session) disposes the token it replaces. A job's view
    # reads its parent's session (see New-VMetricJobSession). The text goes into the Authorization header and
    # nowhere else.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Session
    )

    $source = $Session
    if ($Session.IsJobSnapshot -and $Session.TokenSource) {
        $source = $Session.TokenSource
    }
    [System.Threading.Monitor]::Enter($source.Lock)
    try {
        if (-not $source.AccessToken) {
            return $null
        }
        [pscustomobject]@{
            Generation = [int]$source.Generation
            Text       = ConvertFrom-VMetricSecureString -SecureString $source.AccessToken
            ExpiresAt  = $source.AccessExpiresAt
        }
    }
    finally {
        [System.Threading.Monitor]::Exit($source.Lock)
    }
}

function Invoke-VMetricRequest {
    # A request to <API base>v1<Path>. Answers the parsed JSON body ($null for an empty one). A failure is a
    # terminating ErrorRecord whose exception is a VMetricException (StatusCode, Code, TraceId, Details).
    [CmdletBinding()]
    param(
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string] $Method = 'GET',

        # Below /api/v1: /devices, /deployments/resources/read.
        [Parameter(Mandatory)]
        [string] $Path,

        [System.Collections.IDictionary] $Query,

        # Serialized as JSON; a SecureString in it is sent as its text.
        [AllowNull()]
        [object] $Body,

        # The session to use; the current one by default.
        [System.Collections.IDictionary] $Session,

        # No Authorization header (the sign-in endpoints); requires -ApiUrl.
        [switch] $Anonymous,

        # The API's URL (https://api.example.com/api/) or an origin serving it under /api/.
        [Alias('Origin')]
        [string] $ApiUrl,

        [switch] $SkipCertificateCheck,

        # Do not wait out another deployment on 409 DEPLOYMENT_IN_PROGRESS.
        [switch] $NoConflictRetry,

        # Answer ordered dictionaries instead of PSCustomObjects.
        [switch] $AsHashtable,

        [int] $TimeoutSec = 120
    )

    $target = "$Method $Path"
    if (-not $Anonymous -and -not $Session) {
        try {
            $Session = Get-VMetricSession
        }
        catch {
            throw (New-VMetricErrorRecord -Exception $_.Exception -TargetObject $target)
        }
    }
    if ($Session) {
        $ApiUrl = $Session.ApiUrl
    }
    if (-not $ApiUrl) {
        throw (New-VMetricErrorRecord -TargetObject $target -Exception (
                    New-VMetricException -Message 'No address to send the request to.' -Code 'NoOrigin' -Category InvalidArgument))
    }
    $skipCertificate = $SkipCertificateCheck.IsPresent -or ($Session -and [bool]$Session.SkipCertificateCheck)
    $uri = New-VMetricRequestUri -ApiUrl $ApiUrl -Path $Path -Query $Query
    $Origin = ([uri]$uri).GetLeftPart([System.UriPartial]::Authority)
    $json = $null
    $secrets = $null
    if ($PSBoundParameters.ContainsKey('Body')) {
        $json = ConvertTo-VMetricJson -InputObject $Body
        $secrets = Get-VMetricSecretText -InputObject $Body
    }

    $authRetried = $false
    $conflictWaited = 0.0
    $conflictDelay = 1.0
    $transientRetries = 0
    while ($true) {
        $headers = @{ Accept = 'application/json' }
        $generation = 0
        if (-not $Anonymous) {
            if (Test-VMetricAccessTokenExpiring -Session $Session) {
                Update-VMetricAccessToken -Session $Session
            }
            $bearer = Get-VMetricBearerToken -Session $Session
            if (-not $bearer) {
                if ($Session.IsJobSnapshot) {
                    $ended = New-VMetricException -Message $script:VMetricJobSessionEnded -Code 'JobTokenExpired' -Category AuthenticationError -Terminating
                }
                else {
                    $ended = New-VMetricException -Message 'Your session has ended. Run Connect-VMetric to sign in again.' -Code 'SessionExpired' `
                        -Category AuthenticationError -Terminating
                }
                throw (New-VMetricErrorRecord -TargetObject $target -Exception $ended)
            }
            $generation = $bearer.Generation
            $headers.Authorization = 'Bearer ' + $bearer.Text
            $bearer = $null
        }

        Write-Verbose "$Method $uri"
        $started = [System.Diagnostics.Stopwatch]::StartNew()
        try {
            $response = Invoke-VMetricHttp -Method $Method -Uri $uri -Headers $headers -Body $json -SkipCertificateCheck:$skipCertificate -TimeoutSec $TimeoutSec
        }
        catch {
            if (Test-VMetricFlowControl -ErrorRecord $_) {
                throw
            }
            $failure = $_.Exception
            if ($Method -eq 'GET' -and $transientRetries -lt 2) {
                $transientRetries++
                Write-Verbose "No answer ($($failure.Message)); retrying in $transientRetries s."
                Wait-VMetricInterval -Seconds $transientRetries
                continue
            }
            $category = [System.Management.Automation.ErrorCategory]::ConnectionError
            $message = "Could not reach ${Origin}: $($failure.Message)"
            if ($failure -is [System.Threading.Tasks.TaskCanceledException] -or $failure -is [System.TimeoutException]) {
                $category = [System.Management.Automation.ErrorCategory]::OperationTimeout
                $message = "$Origin did not answer within $TimeoutSec seconds."
            }
            elseif ("$($failure.Message) $($failure.InnerException.Message)" -match 'SSL|certificate|TLS') {
                $category = [System.Management.Automation.ErrorCategory]::SecurityError
            }
            throw (New-VMetricErrorRecord -TargetObject $target -Exception (
                        New-VMetricException -Message $message -Code 'ConnectionFailed' -Category $category -InnerException $failure))
        }
        $status = [int]$response.StatusCode
        Write-Verbose "$status in $($started.ElapsedMilliseconds) ms"

        if ($status -ge 300 -and $status -lt 400) {
            throw (New-VMetricErrorRecord -TargetObject $target -Exception (
                        New-VMetricRedirectException -Uri $uri -StatusCode $status -Location (Get-VMetricHeader -Response $response -Name 'Location')))
        }
        if ($status -lt 300) {
            if ($status -eq 204 -or [string]::IsNullOrWhiteSpace($response.Content)) {
                return $null
            }
            try {
                $parsed = ConvertFrom-VMetricJson -Json $response.Content -AsHashtable:$AsHashtable
            }
            catch {
                throw (New-VMetricErrorRecord -TargetObject $target -Exception (
                            New-VMetricException -Message "$Origin did not answer as the VirtualMetric API ($status, not JSON). Check the address given to Connect-VMetric." `
                                -Code 'UnexpectedResponse' -Category InvalidResult -StatusCode $status))
            }
            if ($parsed -is [array]) {
                return , $parsed
            }
            return $parsed
        }

        $apiError = ConvertFrom-VMetricErrorResponse -StatusCode $status -Content $response.Content
        if ($status -eq 401 -and -not $Anonymous -and -not $authRetried) {
            if ($Session.IsJobSnapshot) {
                # A job never refreshes; it tries once more when its parent's session has refreshed since.
                $current = Get-VMetricBearerToken -Session $Session
                if ($current -and $current.Generation -ne $generation) {
                    $authRetried = $true
                    Write-Verbose 'The access token was refused; the session has a newer one, trying again.'
                    continue
                }
            }
            elseif ($Session.RefreshToken) {
                $authRetried = $true
                Write-Verbose 'The access token was refused; refreshing it and trying again.'
                Update-VMetricAccessToken -Session $Session -Force -UsedGeneration $generation
                continue
            }
        }
        if ($status -eq 409 -and $apiError.Code -eq 'DEPLOYMENT_IN_PROGRESS' -and -not $NoConflictRetry -and $conflictWaited -lt 30) {
            $delay = [Math]::Min($conflictDelay, 30 - $conflictWaited)
            Write-Verbose "Another deployment is running in this organization; trying again in $delay s."
            Wait-VMetricInterval -Seconds $delay
            $conflictWaited += $delay
            $conflictDelay = [Math]::Min($conflictDelay * 2, 8)
            continue
        }
        if ($Method -eq 'GET' -and $status -in @(502, 503, 504) -and $transientRetries -lt 2) {
            $transientRetries++
            Write-Verbose "$status from the server; retrying in $transientRetries s."
            Wait-VMetricInterval -Seconds $transientRetries
            continue
        }

        $exception = New-VMetricApiException -StatusCode $status -ApiError $apiError -Secret $secrets
        if ($status -eq 401 -and -not $Anonymous -and $Session.IsJobSnapshot -and $Session.AuthMethod -ne 'ApiToken') {
            $exception = New-VMetricException -Message $script:VMetricJobSessionEnded -Code 'JobTokenExpired' -Category AuthenticationError `
                -StatusCode 401 -TraceId $apiError.TraceId -Terminating
        }
        elseif ($status -eq 401 -and -not $Anonymous) {
            $exception.IsTerminating = $true
            if ($Session.AuthMethod -eq 'ApiToken') {
                $exception.RecommendedAction = 'The API token was refused: it may be expired or revoked. Run Connect-VMetric with a valid token.'
            }
        }
        throw (New-VMetricErrorRecord -Exception $exception -TargetObject $target)
    }
}