Private/Invoke-JuribaWebRequestWithRetry.ps1

function Invoke-JuribaWebRequestWithRetry {
    <#
        .SYNOPSIS
        Internal helper. Performs a GET via Invoke-WebRequest with a short
        exponential backoff retry for transient failures.

        .DESCRIPTION
        Retries on transient conditions: rate limiting (HTTP 429), server errors
        (HTTP 5xx) and transport-level failures that produced no HTTP response
        (e.g. a dropped connection). All other failures (e.g. 404) are re-thrown
        immediately so that calling functions can apply their own catch semantics.
        When a 429 response carries a Retry-After header its delay is honoured,
        otherwise an exponential backoff is used. Returns the raw response object
        from Invoke-WebRequest.

        .PARAMETER Uri
        The request URI.

        .PARAMETER Headers
        Request headers, including the x-api-key.

        .PARAMETER ContentType
        Optional. Defaults to "application/json".

        .PARAMETER MaxAttempts
        Optional. Total number of attempts before giving up. Defaults to 4.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]$Uri,
        [Parameter(Mandatory=$true)]
        [hashtable]$Headers,
        [Parameter(Mandatory=$false)]
        [string]$ContentType = "application/json",
        [Parameter(Mandatory=$false)]
        [ValidateRange(1, 10)]
        [int]$MaxAttempts = 4
    )

    $attempt = 0
    while ($true) {
        try {
            return Invoke-WebRequest -Uri $Uri -Method GET -Headers $Headers -ContentType $ContentType
        }
        catch {
            # Determine the HTTP status code without tripping StrictMode on
            # exceptions that have no Response property (e.g. DNS failures).
            $status = $null
            $responseProperty = $_.Exception.psobject.Properties['Response']
            if ($responseProperty -and $responseProperty.Value) {
                $status = [int]$responseProperty.Value.StatusCode
            }

            $attempt++
            # Retry rate limiting (429), server errors (5xx) and transport-level
            # failures (no HTTP response, so $status is $null).
            $retryable = ($null -eq $status) -or ($status -eq 429) -or ($status -ge 500)
            if ($attempt -ge $MaxAttempts -or -not $retryable) {
                throw
            }

            # Exponential backoff: 400ms, 800ms, 1600ms, ... Honour Retry-After
            # (best effort) when the server supplies it on a 429.
            $delayMs = [int](200 * [math]::Pow(2, $attempt))
            if ($status -eq 429 -and $responseProperty -and $responseProperty.Value) {
                try {
                    $retryAfter = $responseProperty.Value.Headers.RetryAfter
                    if ($retryAfter -and $retryAfter.Delta -and $retryAfter.Delta.TotalMilliseconds -gt 0) {
                        $delayMs = [int]$retryAfter.Delta.TotalMilliseconds
                    }
                }
                catch {
                    # Header shape differs across PowerShell editions; fall back to backoff.
                    Write-Verbose "Could not parse Retry-After header; using exponential backoff."
                }
            }
            Start-Sleep -Milliseconds $delayMs
        }
    }
}