Private/Invoke-SnowRequest.ps1

function Invoke-SnowRequest {
    <#
    .SYNOPSIS
        REST backend: calls the ServiceNow Table API with pagination, retry/backoff and error parsing.
    .DESCRIPTION
        GET without -SysId pages with sysparm_limit/sysparm_offset until a short page is returned
        or -First records have been emitted. Retries on 429 and 5xx with exponential backoff and
        jitter (honouring Retry-After); non-idempotent POST is only retried on 429, since a 5xx
        may mean the write succeeded. Anything other than GET goes through ShouldProcess.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory)][ValidatePattern('^[a-z0-9_]+$')][string]$Table,
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')][string]$Method = 'GET',
        [ValidatePattern('^[0-9a-fA-F]{32}$')][string]$SysId,
        [string]$Query,
        [string[]]$Fields,
        [ValidateSet('true', 'false', 'all')][string]$DisplayValue,
        [ValidateRange(1, 10000)][int]$PageSize = 1000,
        [ValidateRange(1, [int]::MaxValue)][int]$First,
        [object]$Body,
        [ValidateRange(0, 10)][int]$MaxRetries = 4,
        [ValidateRange(0.0, 60.0)][double]$BaseDelaySeconds = 1.0
    )

    $session = Get-SnowSession
    $path = "/api/now/table/$Table" + $(if ($SysId) { "/$SysId" })
    $isRead = $Method -eq 'GET'
    $paged = $isRead -and -not $SysId

    if (-not $isRead -and -not $PSCmdlet.ShouldProcess("$Table$(if ($SysId) { "/$SysId" })", $Method)) { return }

    $offset = 0
    $emitted = 0
    do {
        $limit = $PageSize
        if ($paged -and $First) { $limit = [Math]::Min($PageSize, $First - $emitted) }

        $qs = [System.Collections.Generic.List[string]]::new()
        if ($Query) { $qs.Add('sysparm_query=' + [uri]::EscapeDataString($Query)) }
        if ($Fields) { $qs.Add('sysparm_fields=' + [uri]::EscapeDataString($Fields -join ',')) }
        if ($DisplayValue) { $qs.Add("sysparm_display_value=$DisplayValue") }
        if ($paged) { $qs.Add("sysparm_limit=$limit"); $qs.Add("sysparm_offset=$offset") }
        $uri = [uri]::new($session.BaseUri, $path + $(if ($qs.Count) { '?' + ($qs -join '&') }))

        $params = @{
            Uri         = $uri
            Method      = $Method
            ErrorAction = 'Stop'
        }
        if ($null -ne $Body) {
            $params.Body = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 10 -Compress }
            $params.ContentType = 'application/json'
        }

        $attempt = 0
        while ($true) {
            $params.Headers = (Get-SnowAuthHeader -Session $session) + @{ Accept = 'application/json' }
            try {
                $response = Invoke-RestMethod @params
                break
            }
            catch {
                $status = $null
                $retryAfter = $null
                if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
                    $status = [int]$_.Exception.Response.StatusCode
                    $values = $null
                    if ($_.Exception.Response.Headers.TryGetValues('Retry-After', [ref]$values)) {
                        $value = @($values)[0].Trim()
                        $parsed = 0.0
                        if ([double]::TryParse(
                                $value,
                                [Globalization.NumberStyles]::AllowDecimalPoint,
                                [Globalization.CultureInfo]::InvariantCulture,
                                [ref]$parsed
                            ) -and $parsed -ge 0) {
                            $retryAfter = $parsed
                        }
                        else {
                            $retryDate = [DateTimeOffset]::MinValue
                            $styles = [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal
                            if ([DateTimeOffset]::TryParse($value, [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$retryDate)) {
                                $retryAfter = [Math]::Max(0, ($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds)
                            }
                        }
                    }
                }
                $retryable = $status -eq 429 -or ($status -ge 500 -and $Method -ne 'POST')
                if (-not $retryable -or $attempt -ge $MaxRetries) {
                    $PSCmdlet.ThrowTerminatingError((ConvertTo-SnowErrorRecord -ErrorRecord $_ -Target $uri.AbsolutePath))
                }
                if ($null -ne $retryAfter -and $retryAfter -gt 120) {
                    throw [System.InvalidOperationException]::new("Retry-After requests a $([Math]::Ceiling($retryAfter))-second wait, exceeding the 120-second maximum supported wait.")
                }
                $delay = if ($null -ne $retryAfter) { $retryAfter }
                else { $BaseDelaySeconds * [Math]::Pow(2, $attempt) * (0.5 + [Random]::Shared.NextDouble() / 2) }
                Write-Verbose "HTTP $status from $($uri.AbsolutePath); retry $($attempt + 1)/$MaxRetries in $([Math]::Round($delay, 2))s."
                Start-SnowSleep -Seconds $delay
                $attempt++
            }
        }

        $records = @(if ($null -ne $response -and $response.PSObject.Properties['result']) { $response.result })
        $records
        $emitted += $records.Count
        $offset += $records.Count
        $more = $paged -and $records.Count -ge $limit -and $records.Count -gt 0 -and (-not $First -or $emitted -lt $First)
    } while ($more)
}