Private/Misc/Invoke-WithRetry.ps1

function Invoke-WithRetry {
    <#
        .SYNOPSIS
            Executes a script block with retry logic for transient failures.

        .DESCRIPTION
            This function provides retry logic for operations that may fail due to transient issues
            such as network timeouts, temporary server errors, or rate limiting.

        .PARAMETER ScriptBlock
            The script block to execute with retry logic.

        .PARAMETER RetryCount
            The maximum number of retry attempts. Default is 3.

        .PARAMETER RetryDelay
            The base delay in seconds between retry attempts. Default is 1 second.
            The actual delay will use exponential backoff with jitter.

        .PARAMETER RetryableExceptions
            Array of exception types that should trigger a retry.
            Defaults to common transient error conditions.

        .PARAMETER RetryableStatusCodes
            Array of HTTP status codes that should trigger a retry.
            Defaults to common transient error conditions.

        .PARAMETER NonRetryableErrorPatterns
            Array of error message patterns that should NOT be retried, even if the HTTP status code
            would normally be retryable. This helps distinguish between transient server errors and
            permanent business logic errors (like duplicate entries).

        .PARAMETER MaxRetryDelay
            The maximum delay in seconds between retries. Default is 30 seconds.

        .PARAMETER UseExponentialBackoff
            Whether to use exponential backoff for retry delays. Default is $true.

        .PARAMETER UseJitter
            Whether to add random jitter to retry delays to avoid thundering herd. Default is $true.

        .EXAMPLE
            $result = Invoke-WithRetry -ScriptBlock {
                Invoke-WebRequest -Uri 'https://api.example.com/data'
            }

        .EXAMPLE
            $result = Invoke-WithRetry -ScriptBlock {
                Get-SomeData
            } -RetryCount 5 -RetryDelay 2
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [scriptblock] $ScriptBlock,

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

        [ValidateRange(0.1, 300)]
        [double] $RetryDelay = 1.0,

        [string[]] $RetryableExceptions = @(
            'System.Net.WebException',
            'System.TimeoutException',
            'System.Net.Http.HttpRequestException',
            'Microsoft.PowerShell.Commands.HttpResponseException'
        ),

        [int[]] $RetryableStatusCodes = @(
            408, # Request Timeout
            429, # Too Many Requests
            500, # Internal Server Error
            502, # Bad Gateway
            503, # Service Unavailable
            504  # Gateway Timeout
        ),

        [string[]] $NonRetryableErrorPatterns = @(
            'Cannot add duplicate',
            'already exists',
            'duplicate entry',
            'unique constraint'
        ),

        [ValidateRange(1, 300)]
        [double] $MaxRetryDelay = 30.0,

        [bool] $UseExponentialBackoff = $true,

        [bool] $UseJitter = $true
    )

    $attempt = 0
    $lastException = $null

    while ($attempt -le $RetryCount) {
        try {
            Write-Verbose "Executing attempt $($attempt + 1) of $($RetryCount + 1)"

            # Execute the script block
            $result = & $ScriptBlock

            # If we get here, the operation succeeded
            if ($attempt -gt 0) {
                Write-Verbose "Operation succeeded on attempt $($attempt + 1)"
            }

            return $result
        } catch {
            $lastException = $_
            $attempt++            # Check if we should retry this exception
            $shouldRetry = $false

            # Check HTTP status codes for web exceptions
            $statusCode = $null
            if ($_.Exception -is [System.Net.WebException]) {
                if ($_.Exception.Response) {
                    $statusCode = [int]$_.Exception.Response.StatusCode
                }
            } elseif ($_.Exception.GetType().FullName -eq 'Microsoft.PowerShell.Commands.HttpResponseException') {
                # Use type name comparison for PowerShell version compatibility
                $statusCode = [int]$_.Exception.Response.StatusCode
            } elseif ($_.TargetObject -and $_.TargetObject.StatusCode) {
                # For mock objects in tests
                $statusCode = [int]$_.TargetObject.StatusCode
            }

            # For HTTP exceptions, only retry if the status code is in the retryable list
            if ($statusCode) {
                if ($statusCode -in $RetryableStatusCodes) {
                    $shouldRetry = $true
                    Write-Verbose "Retryable HTTP status code detected: $statusCode"
                } else {
                    Write-Verbose "Non-retryable HTTP status code detected: $statusCode"
                }
            } else {
                # For non-HTTP exceptions, check exception type
                $exceptionType = $_.Exception.GetType().FullName
                if ($exceptionType -in $RetryableExceptions) {
                    $shouldRetry = $true
                    Write-Verbose "Retryable exception detected: $exceptionType"
                }
            }

            # Check if the error message contains non-retryable patterns
            $errorMessage = $_.Exception.Message
            if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
                $errorMessage = $_.ErrorDetails.Message
            }

            $isNonRetryablePattern = $false
            foreach ($pattern in $NonRetryableErrorPatterns) {
                if ($errorMessage -like "*$pattern*") {
                    $isNonRetryablePattern = $true
                    Write-Verbose "Non-retryable error pattern detected: '$pattern' in message: $errorMessage"
                    break
                }
            }

            # If we found a non-retryable pattern, don't retry regardless of status code or exception type
            if ($shouldRetry -and $isNonRetryablePattern) {
                Write-Verbose "Skipping retry due to non-retryable error pattern"
                $shouldRetry = $false
            }
            # If this is our last attempt or the error is not retryable, throw
            if ($attempt -gt $RetryCount -or -not $shouldRetry) {
                if (-not $shouldRetry) {
                    Write-Verbose "Non-retryable error encountered: $exceptionType $(if ($statusCode) { "HTTP $statusCode" })"
                } else {
                    Write-Verbose "Maximum retry attempts ($RetryCount) exceeded"
                }

                # Use Assert-HttpResponse to parse JSON error messages for better error details
                try {
                    Assert-HttpResponse -ErrorRecord $lastException
                } catch {
                    # If Assert-HttpResponse fails or doesn't apply, throw the original exception
                    throw $lastException
                }
            }

            # Calculate delay for next attempt
            $delay = $RetryDelay

            if ($UseExponentialBackoff) {
                # Exponential backoff: delay = base * (2 ^ attempt)
                $delay = $RetryDelay * [Math]::Pow(2, $attempt - 1)
            }

            # Apply jitter (random ±25% variation)
            if ($UseJitter) {
                $jitterRange = $delay * 0.25
                $jitter = (Get-Random -Minimum (-$jitterRange) -Maximum $jitterRange)
                $delay += $jitter
            }

            # Cap the delay at maximum
            $delay = [Math]::Min($delay, $MaxRetryDelay)

            Write-Verbose "Retrying in $([Math]::Round($delay, 2)) seconds (attempt $attempt of $RetryCount)"
            Write-Verbose "Operation failed, retrying in $([Math]::Round($delay, 2)) seconds. Error: $($_.Exception.Message)"

            # Wait before retrying
            Start-Sleep -Seconds $delay
        }
    }

    # This should never be reached, but just in case
    throw $lastException
}