tests/Invoke-MsecRetryableRequest.Tests.ps1

#Requires -Module Pester
#
# Tests for Invoke-MsecRetryableRequest - the shared throttling retry used by both the Graph
# and the ARM callers.
#
# The traps:
#
# RETRYING THE WRONG STATUS WASTES TIME AND HIDES THE MESSAGE. 403 and 404 cannot be fixed
# by waiting, so they must surface immediately.
#
# AN UNBOUNDED RETRY IS WORSE THAN A FAILURE. A bulk operation that never finishes cannot be
# resumed from; one that stops and says why can.
#
# Start-Sleep IS MOCKED THROUGHOUT, so the suite does not actually wait.

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'msec.psm1') -Force -ErrorAction Stop
}
AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue }

Describe 'Invoke-MsecRetryableRequest' {

    It 'retries a 429 and returns the eventual success' {
        # THE BUG THIS PREVENTS. Closing a few hundred Sentinel incidents is one PUT each and
        # ARM throttles per subscription - reported from a live run of 240, one write was
        # throttled. Without a retry that lands mid-run with some written and some not.
        $r = InModuleScope Msec {
            Mock Start-Sleep -MockWith { }
            $script:N = 0
            Mock Invoke-RestMethod -MockWith {
                $script:N++
                if ($script:N -lt 3) { throw 'Response status code does not indicate success: 429 (Too Many Requests).' }
                [pscustomobject]@{ ok = $true; attempts = $script:N }
            }
            Invoke-MsecRetryableRequest -RequestArgs @{ Uri = 'https://example.invalid' }
        }

        $r.ok       | Should -BeTrue
        $r.attempts | Should -Be 3
    }

    It 'retries 503 and 504 as well, which are transient by the services own guidance' {
        foreach ($code in '503 (Service Unavailable)', '504 (Gateway Timeout)') {
            $r = InModuleScope Msec -Parameters @{ code = $code } {
                param($code)
                Mock Start-Sleep -MockWith { }
                $script:N = 0
                Mock Invoke-RestMethod -MockWith {
                    $script:N++
                    if ($script:N -lt 2) { throw "Response status code does not indicate success: $code." }
                    [pscustomobject]@{ attempts = $script:N }
                }
                Invoke-MsecRetryableRequest -RequestArgs @{ Uri = 'https://example.invalid' }
            }
            $r.attempts | Should -Be 2 -Because "$code is transient and should be retried"
        }
    }

    It 'does NOT retry a 403, because waiting cannot grant a permission' {
        # Retrying here would delay a message the caller needs now, four times over.
        $n = InModuleScope Msec {
            Mock Start-Sleep -MockWith { }
            $script:N = 0
            Mock Invoke-RestMethod -MockWith {
                $script:N++
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }
            try { Invoke-MsecRetryableRequest -RequestArgs @{ Uri = 'https://example.invalid' } | Out-Null } catch { }
            $script:N
        }

        $n | Should -Be 1
    }

    It 'gives up after MaxAttempts rather than retrying forever' {
        $n = InModuleScope Msec {
            Mock Start-Sleep -MockWith { }
            $script:N = 0
            Mock Invoke-RestMethod -MockWith {
                $script:N++
                throw 'Response status code does not indicate success: 429 (Too Many Requests).'
            }
            try { Invoke-MsecRetryableRequest -RequestArgs @{ Uri = 'https://example.invalid' } -MaxAttempts 3 | Out-Null } catch { }
            $script:N
        }

        $n | Should -Be 3
    }

    It 'rethrows the original error when it gives up, not a wrapper' {
        # The caller's 403/429 handling keys on the message; swallowing it would break the
        # messages the Sentinel commands build from it.
        $msg = InModuleScope Msec {
            Mock Start-Sleep -MockWith { }
            Mock Invoke-RestMethod -MockWith { throw 'Response status code does not indicate success: 429 (Too Many Requests).' }
            try { Invoke-MsecRetryableRequest -RequestArgs @{ Uri = 'https://example.invalid' } -MaxAttempts 2 | Out-Null } catch { $_.Exception.Message }
        }

        $msg | Should -BeLike '*429*'
    }

    It 'backs off between attempts, capped so one bad call cannot stall a run' {
        $waits = InModuleScope Msec {
            $script:Waits = @()
            Mock Start-Sleep -MockWith { $script:Waits += $Seconds }
            Mock Invoke-RestMethod -MockWith { throw 'Response status code does not indicate success: 429 (Too Many Requests).' }
            try { Invoke-MsecRetryableRequest -RequestArgs @{ Uri = 'https://example.invalid' } -MaxAttempts 5 | Out-Null } catch { }
            $script:Waits
        }

        @($waits).Count | Should -Be 4
        ($waits | Measure-Object -Maximum).Maximum | Should -BeLessOrEqual 30
    }
}