Private/Invoke-MyWebApiRequest.ps1

# * The server may take longer than the requested timeout to answer: time spent opening the
# trade-platform connection extends its deadline by up to 20 s. The HTTP call's own deadline
# therefore sits this far beyond the server's, so the server's answer (Timeout, OutcomeUnknown)
# arrives instead of an ambiguous client-side abort.
$script:MyWebApiServerDeadlineMarginSeconds = 30
# * Operations the spec does not annotate with a default timeout are assumed to take the
# longest server default (server maintenance, 60 s) when sizing the HTTP deadline.
$script:MyWebApiUnknownServerTimeoutSeconds = 60

function Get-MyWebApiErrorGuidance {
    # Plain-language next step for the error codes and outcomes whose handling is not obvious.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [string] $Code,
        [string] $Outcome,
        [string] $IdempotencyKey
    )
    switch ($Code) {
        'Timeout' {
            return 'The trading platform did not answer in time. Nothing was changed; the request is safe to repeat. Allow more time with -RequestTimeout.'
        }
        'Busy' {
            return 'The trading platform is busy: the request was refused before it was sent and nothing was changed. Repeat it after a short pause.'
        }
        'OutcomeUnknown' {
            if ($Outcome -eq 'in-progress') {
                return 'A request with the same Idempotency-Key is still being processed and was not executed again. Repeat later with the same -IdempotencyKey to receive its result.'
            }
            if ($IdempotencyKey) {
                return 'The operation did not finish in time and may still be applied by the trading platform. Do not repeat it blindly: repeat with the same -IdempotencyKey to receive the original result, or check the result first.'
            }
            return 'The operation did not finish in time and may still be applied by the trading platform. Do not repeat it blindly: check the result first. Pass -IdempotencyKey on writes so that a repeat returns the original result instead of executing twice.'
        }
    }
    return $null
}

function ConvertTo-MyWebApiErrorRecord {
    # Builds the terminating error for a v2 error envelope or a client-side HTTP deadline. The
    # same details are on TargetObject (for $_.TargetObject.Code) and on Exception.Data (for
    # callers that only keep the exception).
    [CmdletBinding()]
    [OutputType([System.Management.Automation.ErrorRecord])]
    param(
        [Parameter(Mandatory)][string] $ErrorId,
        [Parameter(Mandatory)][string] $Message,
        [Parameter(Mandatory)][hashtable] $Details,
        [System.Exception] $InnerException
    )
    $guidance = $Details['Guidance']
    $fullMessage = if ($guidance) { "$Message $guidance" } else { $Message }
    $exception = if ($InnerException) { [System.Exception]::new($fullMessage, $InnerException) } else { [System.Exception]::new($fullMessage) }
    foreach ($key in $Details.Keys) { $exception.Data[$key] = $Details[$key] }

    $category = switch ($Details['Code']) {
        'Timeout'        { [System.Management.Automation.ErrorCategory]::OperationTimeout }
        'OutcomeUnknown' { [System.Management.Automation.ErrorCategory]::OperationTimeout }
        'Busy'           { [System.Management.Automation.ErrorCategory]::ResourceBusy }
        default {
            if ($Details['Source'] -eq 'client') { [System.Management.Automation.ErrorCategory]::OperationTimeout }
            else { [System.Management.Automation.ErrorCategory]::InvalidOperation }
        }
    }
    $record = [System.Management.Automation.ErrorRecord]::new($exception, $ErrorId, $category, [pscustomobject]$Details)
    if ($guidance) {
        $record.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($fullMessage)
        $record.ErrorDetails.RecommendedAction = $guidance
    }
    return $record
}

function Invoke-MyWebApiRequest {
    # Single choke point for every REST cmdlet: auth, URL build, timeouts, envelope unwrap, paging.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $Method,
        [Parameter(Mandatory)][string] $Path,
        [hashtable] $Query,
        [object]    $Body,
        [string]    $TradePlatform,
        [Nullable[guid]] $CacheId,
        [int]       $CacheTimeout,
        [string]    $IdempotencyKey,
        [ValidateRange(1, 300)][double] $RequestTimeout,
        [ValidateRange(1, 300)][double] $DefaultRequestTimeout,
        [switch]    $All,
        [object]    $Connection
    )

    $ctx = Resolve-MyWebApiContext -Connection $Connection
    $safeMethod = $Method.ToUpperInvariant() -in @('GET', 'HEAD', 'OPTIONS')
    $configuredRetries = Get-MyWebApiContextProperty -Context $ctx -Name 'MaxGetRetries'
    $maxRetries = if ($safeMethod -and $null -ne $configuredRetries) { [Math]::Min([int]$configuredRetries, 3) } else { 0 }
    $platformCall = $Path -like '*{tradePlatform}*'

    # Resolve {tradePlatform} from the explicit connection or the session default.
    if ($platformCall) {
        $defaultPlatform = Get-MyWebApiContextProperty -Context $ctx -Name 'DefaultTradePlatform'
        $tp = if ($TradePlatform) { $TradePlatform } else { $defaultPlatform }
        if (-not $tp) { throw 'No trade platform: pass -TradePlatform or set -DefaultTradePlatform on Connect-MyWebApi.' }
        $Path = $Path.Replace('{tradePlatform}', [uri]::EscapeDataString($tp))
    }

    # * Request timeout: the cmdlet's -RequestTimeout wins over the session default; with neither,
    # no header is sent and the server applies the operation's own default.
    $requestedTimeout = $null
    if ($PSBoundParameters.ContainsKey('RequestTimeout')) {
        $requestedTimeout = $RequestTimeout
    } else {
        $sessionTimeout = Get-MyWebApiContextProperty -Context $ctx -Name 'RequestTimeout'
        if ($null -ne $sessionTimeout) {
            $parsed = 0.0
            if (-not [double]::TryParse([string]$sessionTimeout, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed) -or $parsed -lt 1 -or $parsed -gt 300) {
                throw "The session RequestTimeout must be a number of seconds from 1 to 300; got '$sessionTimeout'."
            }
            $requestedTimeout = $parsed
        }
    }
    $serverTimeout = if ($null -ne $requestedTimeout) { $requestedTimeout }
                     elseif ($PSBoundParameters.ContainsKey('DefaultRequestTimeout')) { $DefaultRequestTimeout }
                     elseif ($platformCall) { $script:MyWebApiUnknownServerTimeoutSeconds }
                     else { $null }
    # * HTTP deadline: an explicitly requested server timeout always gets its margin. Otherwise an
    # HttpTimeoutSeconds the caller chose on Connect-MyWebApi is a hard cap and is kept as is;
    # the module default is only a floor under the operation's server default plus the margin.
    $httpTimeout = Get-MyWebApiTimeoutSeconds -Context $ctx
    $httpTimeoutExplicit = [bool](Get-MyWebApiContextProperty -Context $ctx -Name 'HttpTimeoutExplicit')
    if ($null -ne $serverTimeout -and ($null -ne $requestedTimeout -or -not $httpTimeoutExplicit)) {
        $needed = [int][Math]::Ceiling($serverTimeout + $script:MyWebApiServerDeadlineMarginSeconds)
        $httpTimeout = [Math]::Min(600, [Math]::Max($httpTimeout, $needed))
    }

    $token = Get-MyWebApiToken -Connection $ctx
    $headers = @{ Authorization = "Bearer $token"; Accept = 'application/json' }
    if ($IdempotencyKey) { $headers['Idempotency-Key'] = $IdempotencyKey }
    if ($null -ne $requestedTimeout) {
        $headers['X-Request-Timeout'] = ([double]$requestedTimeout).ToString('0.###', [System.Globalization.CultureInfo]::InvariantCulture)
    }

    $q = @{}
    if ($Query) { foreach ($k in $Query.Keys) { if ($null -ne $Query[$k]) { $q[$k] = $Query[$k] } } }
    if ($CacheId)      { $q['cacheId']      = $CacheId.ToString() }
    if ($CacheTimeout) { $q['cacheTimeout'] = $CacheTimeout }

    $accumulated = [System.Collections.Generic.List[object]]::new()
    $cursor = $null
    $more = $false

    do {
        if ($All -and $cursor) { $q['cursor'] = $cursor }
        $qsParts = [System.Collections.Generic.List[string]]::new()
        foreach ($kv in $q.GetEnumerator()) {
            foreach ($v in @($kv.Value)) {
                $qsParts.Add(("{0}={1}" -f [uri]::EscapeDataString([string]$kv.Key), [uri]::EscapeDataString([string]$v)))
            }
        }
        $qs = $qsParts -join '&'
        $baseUrl = [string](Get-MyWebApiContextProperty -Context $ctx -Name 'BaseUrl')
        $uri = "$baseUrl$Path"
        if ($qs) { $uri = "$uri`?$qs" }

        # GET/HEAD/OPTIONS are safe to retry with a bounded policy: transport failures, retryable
        # HTTP statuses and a Busy refusal (nothing was sent to the platform). A Timeout is not
        # retried automatically -- the platform is slow, and the caller decides whether to wait
        # longer. Writes are always one-shot, regardless of Idempotency-Key: a lost response or an
        # OutcomeUnknown is an ambiguous outcome that only the caller can resolve.
        $attempt = 0
        while ($true) {
            $responseHeaders = @{}
            $irmArgs = @{
                Context = $ctx
                Method = $Method
                Uri = $uri
                Headers = $headers
                JsonBody = $Body
                TimeoutSeconds = $httpTimeout
                ResponseHeaders = $responseHeaders
            }
            $retryDelay = $false
            try {
                Assert-MyWebApiNotCancelled -Context $ctx
                $resp = Invoke-MyWebApiHttpJson @irmArgs
                $errProbe = Get-MyWebApiContextProperty -Context $resp -Name 'error'
                $busy = $errProbe -and ((Get-MyWebApiContextProperty -Context $errProbe -Name 'code') -eq 'Busy')
                if ($busy -and $safeMethod -and $attempt -lt $maxRetries) { $retryDelay = $true } else { break }
            } catch {
                $statusCode = $null
                $responseProperty = $_.Exception.PSObject.Properties['Response']
                if ($responseProperty -and $responseProperty.Value) {
                    try { $statusCode = [int]$responseProperty.Value.StatusCode } catch { $statusCode = $null }
                }
                if ($null -eq $statusCode) {
                    $dataStatus = $_.Exception.Data['StatusCode']
                    if ($null -ne $dataStatus) { $statusCode = [int]$dataStatus }
                }
                $retryableStatus = $statusCode -in @(408, 425, 429, 500, 502, 503, 504)
                $retryable = $safeMethod -and $attempt -lt $maxRetries -and ($retryableStatus -or $null -eq $statusCode)
                if (-not $retryable) {
                    if ($_.Exception -is [System.TimeoutException]) {
                        # * The HTTP deadline passed without any answer. For a write this is the
                        # same ambiguity as OutcomeUnknown and is reported the same way.
                        $details = @{
                            Code = $null; Source = 'client'; Outcome = if ($safeMethod) { 'timeout' } else { 'unknown' }
                            Retryable = $safeMethod; Method = $Method.ToUpperInvariant(); Path = $Path
                            IdempotencyKey = $IdempotencyKey; RequestTimeout = $requestedTimeout; HttpTimeoutSeconds = $httpTimeout
                            ActivityId = $null; ManagerCode = $null; RequestTimeoutApplied = $null
                            Guidance = Get-MyWebApiErrorGuidance -Code $(if ($safeMethod) { 'Timeout' } else { 'OutcomeUnknown' }) -IdempotencyKey $IdempotencyKey
                        }
                        $PSCmdlet.ThrowTerminatingError((ConvertTo-MyWebApiErrorRecord -ErrorId 'MyWebApiHttpTimeout' `
                            -Message ("No HTTP response within {0} s." -f $httpTimeout) -Details $details -InnerException $_.Exception))
                    }
                    throw
                }
                $retryDelay = $true
            }
            if ($retryDelay) {
                $delayMs = [Math]::Min(250 * [Math]::Pow(2, $attempt), 2000)
                if ((Get-MyWebApiCancellationToken -Context $ctx).WaitHandle.WaitOne([int]$delayMs)) {
                    throw [System.OperationCanceledException]::new('The WebAPI session operation was cancelled.')
                }
                $attempt++
            }
        }

        $errObj = Get-MyWebApiContextProperty -Context $resp -Name 'error'
        if ($errObj) {
            $metaObj     = Get-MyWebApiContextProperty -Context $resp -Name 'meta'
            $activityId  = Get-MyWebApiContextProperty -Context $metaObj -Name 'activityId'
            $code        = [string](Get-MyWebApiContextProperty -Context $errObj -Name 'code')
            $errMessage  = Get-MyWebApiContextProperty -Context $errObj -Name 'message'
            $managerCode = Get-MyWebApiContextProperty -Context $errObj -Name 'managerCode'
            $outcome     = $responseHeaders['X-Request-Outcome']
            $applied     = $responseHeaders['X-Request-Timeout-Applied']
            $msg = if ($errMessage) { $errMessage } else { "v2 error: $code" }
            $suffix = "code=$code; activityId=$activityId; managerCode=$managerCode"
            if ($outcome) { $suffix += "; outcome=$outcome" }
            if ($applied) { $suffix += "; requestTimeoutApplied=$applied" }
            $details = @{
                Code = $code; Source = 'server'; Outcome = $outcome; RequestTimeoutApplied = $applied
                Retryable = $code -in @('Timeout', 'Busy'); Method = $Method.ToUpperInvariant(); Path = $Path
                IdempotencyKey = $IdempotencyKey; RequestTimeout = $requestedTimeout; HttpTimeoutSeconds = $httpTimeout
                ActivityId = $activityId; ManagerCode = $managerCode
                Guidance = Get-MyWebApiErrorGuidance -Code $code -Outcome $outcome -IdempotencyKey $IdempotencyKey
            }
            $PSCmdlet.ThrowTerminatingError((ConvertTo-MyWebApiErrorRecord -ErrorId "MyWebApiError,$code" -Message "$msg ($suffix)." -Details $details))
        }

        $dataObj = Get-MyWebApiContextProperty -Context $resp -Name 'data'
        if ($All) {
            if ($null -ne $dataObj) {
                foreach ($item in @($dataObj)) { $accumulated.Add($item) }
            }
            $metaObj = Get-MyWebApiContextProperty -Context $resp -Name 'meta'
            $paging  = Get-MyWebApiContextProperty -Context $metaObj -Name 'paging'
            $cursor  = Get-MyWebApiContextProperty -Context $paging -Name 'nextCursor'
            $more    = [bool]($paging -and (Get-MyWebApiContextProperty -Context $paging -Name 'hasMore') -and $cursor)
        } else {
            return $dataObj
        }
    } while ($All -and $more)

    return $accumulated.ToArray()
}