Private/Invoke-KB4Request.ps1

function Invoke-KB4Request
{
    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string] $Method = 'GET',

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

        [Parameter()]
        [hashtable] $Query = @{},

        [Parameter()]
        [AllowNull()]
        [object] $Body,

        [Parameter()]
        [ValidateRange(0, 10)]
        [int] $MaximumRetryCount = 3
    )

    $headers = Get-KB4AuthorizationHeader
    $uri = Resolve-KB4Uri -Path $Path -Query $Query
    $attempt = 0

    do
    {
        $attempt++
        # Rate limiting is centralized so all public commands behave consistently.
        Invoke-KB4RateLimitDelay

        $responseHeaders = $null
        $statusCode = $null

        $requestParameters = @{
            Method                  = $Method
            Uri                     = $uri
            Headers                 = $headers
            ResponseHeadersVariable = 'responseHeaders'
            StatusCodeVariable      = 'statusCode'
            SkipHttpErrorCheck      = $true
        }

        if ($null -ne $Body)
        {
            $requestParameters['Body'] = $Body
            $requestParameters['ContentType'] = 'application/json'
        }

        if ($null -ne $script:KB4InvokeRestMethodOverride)
        {
            # Unit tests inject a fake transport here; production uses Invoke-RestMethod.
            $transportResponse = & $script:KB4InvokeRestMethodOverride $requestParameters
            $responseBody = $transportResponse.Body
            $responseHeaders = $transportResponse.Headers
            $statusCode = $transportResponse.StatusCode
        }
        else
        {
            $responseBody = Invoke-RestMethod @requestParameters
        }

        if ($statusCode -lt 400)
        {
            return [pscustomobject] @{
                Body      = $responseBody
                Headers   = $responseHeaders
                StatusCode = $statusCode
                RequestId = Get-KB4HeaderValue -Headers $responseHeaders -Name @('X-Request-Id', 'x-request-id')
                Uri       = $uri
            }
        }

        $retryable = $statusCode -eq 429 -or ($statusCode -ge 500 -and $statusCode -lt 600)
        if ($retryable -and $attempt -le $MaximumRetryCount)
        {
            $retryAfter = Get-KB4HeaderValue -Headers $responseHeaders -Name @('Retry-After', 'retry-after')
            $delaySeconds = 1

            # Honor server-provided retry guidance before falling back to backoff.
            if ($retryAfter -as [int])
            {
                $delaySeconds = [int] $retryAfter
            }
            else
            {
                $delaySeconds = [Math]::Min([Math]::Pow(2, $attempt - 1), 30)
            }

            Start-Sleep -Seconds $delaySeconds
            continue
        }

        $message = "KnowBe4 Reporting API request failed with HTTP status $statusCode."
        $requestId = Get-KB4HeaderValue -Headers $responseHeaders -Name @('X-Request-Id', 'x-request-id')
        if (-not [string]::IsNullOrWhiteSpace([string] $requestId))
        {
            $message = "$message Request ID: $requestId."
        }

        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            [System.Exception]::new($message),
            'KB4ReportingApiRequestFailed',
            [System.Management.Automation.ErrorCategory]::InvalidOperation,
            $uri
        )
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
    while ($attempt -le $MaximumRetryCount)
}