Private/Invoke-WithRetry.ps1

function Invoke-WithRetry {
    <#
        Run a PnP call, and if SharePoint throttles it (429 / 503), wait and try
        again. Tenant-wide auditing makes thousands of calls; without this, the
        first throttle ends the run.

        Honours the server's Retry-After header when it is present - that is the
        wait SharePoint is actually asking for. Falls back to exponential backoff
        otherwise. Anything that is NOT a throttle is rethrown immediately, because
        retrying a 404 or an auth failure just wastes time.
    #>

    param(
        [Parameter(Mandatory)] [scriptblock] $Action,
        [int]    $MaxAttempts = 5,
        [string] $Because = 'call'
    )

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            return & $Action
        }
        catch {
            $ex  = $_.Exception
            $msg = $ex.Message

            # Pull an HTTP status if one is reachable on the exception chain.
            $status = $null
            $probe  = $ex
            while ($probe -and -not $status) {
                if ($probe.PSObject.Properties['Response'] -and $probe.Response) {
                    # Not every exception's Response exposes a numeric StatusCode;
                    # if this one does not, fall through to message matching.
                    try { $status = [int]$probe.Response.StatusCode } catch { Write-Debug "no StatusCode on this response" }
                }
                $probe = $probe.InnerException
            }

            $isThrottle =
                $status -in 429, 503 -or
                $msg -match '(?i)\b429\b|too many requests|throttl|\b503\b|service unavailable'

            if (-not $isThrottle -or $attempt -eq $MaxAttempts) {
                throw
            }

            # Prefer the server's Retry-After; otherwise back off exponentially.
            $wait = $null
            $probe = $ex
            while ($probe -and -not $wait) {
                if ($probe.PSObject.Properties['Response'] -and $probe.Response) {
                    try {
                        $ra = $probe.Response.Headers['Retry-After']
                        if ($ra) { $wait = [int]$ra }
                    } catch { Write-Debug "no Retry-After header available" }
                }
                $probe = $probe.InnerException
            }
            if (-not $wait) { $wait = [Math]::Min([Math]::Pow(2, $attempt), 60) }

            Write-Warning "Throttled on $Because (attempt $attempt/$MaxAttempts). Waiting $wait s."
            Start-Sleep -Seconds $wait
        }
    }
}