Public/Tests/Invoke-WithRetry.Tests.ps1
|
BeforeAll { $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config' $env:TCS_SKIP_UPDATE_CHECK = '1' $env:TCS_TELEMETRY_OPTOUT = '1' $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.core.psd1') -Force } AfterAll { Remove-Module -Name tcs.core -Force -ErrorAction SilentlyContinue } Describe 'Invoke-WithRetry' { It "Should return result on success" { $result = Invoke-WithRetry -ScriptBlock { "hello" } $result | Should -Be "hello" } It "Should retry on failure" { $script:counter = 0 $result = Invoke-WithRetry -ScriptBlock { $script:counter++ if ($script:counter -lt 3) { throw "Transient failure" } "success" } -MaxRetries 5 -DelaySeconds 0 $result | Should -Be "success" $script:counter | Should -Be 3 } It "Should throw after max retries exhausted" { { Invoke-WithRetry -ScriptBlock { throw "Permanent failure" } -MaxRetries 2 -DelaySeconds 0 } | Should -Throw } It "Should apply delay between retries with BackoffMultiplier without error" { $script:counter = 0 { Invoke-WithRetry -ScriptBlock { $script:counter++ if ($script:counter -lt 2) { throw "Transient" } "done" } -MaxRetries 3 -DelaySeconds 0 -BackoffMultiplier 2 } | Should -Not -Throw } } Describe 'Invoke-WithRetry timing and errors' { BeforeEach { Mock -ModuleName tcs.core Start-Sleep { } } It 'Waits DelaySeconds before the first retry, then applies the backoff' { { Invoke-WithRetry -ScriptBlock { throw 'x' } -MaxRetries 3 -DelaySeconds 1 -BackoffMultiplier 2 } | Should -Throw Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 1000 } Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 2000 } Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 4000 } } It 'Supports fractional delays' { { Invoke-WithRetry -ScriptBlock { throw 'x' } -MaxRetries 1 -DelaySeconds 0.25 } | Should -Throw Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 250 } } It 'Caps the delay at MaxDelaySeconds' { { Invoke-WithRetry -ScriptBlock { throw 'x' } -MaxRetries 2 -DelaySeconds 10 -BackoffMultiplier 10 -MaxDelaySeconds 15 } | Should -Throw Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 15000 } } It 'Rethrows the original exception type after the last attempt' { { Invoke-WithRetry -ScriptBlock { throw [System.IO.IOException]::new('disk') } -MaxRetries 1 -DelaySeconds 0 } | Should -Throw -ExceptionType ([System.IO.IOException]) } It 'Does not retry exceptions outside RetryableExceptions' { $script:calls = 0 { Invoke-WithRetry -ScriptBlock { $script:calls++; throw [System.ArgumentException]::new('bad') } -RetryableExceptions ([System.IO.IOException]) -DelaySeconds 0 } | Should -Throw -ExceptionType ([System.ArgumentException]) $script:calls | Should -Be 1 } It 'Passes the exception and attempt number to OnRetry' { $script:seen = @() { Invoke-WithRetry -ScriptBlock { throw 'x' } -MaxRetries 2 -DelaySeconds 0 -OnRetry { param($ex, $n) $script:seen += $n } } | Should -Throw $script:seen | Should -Be @(1, 2) } } Describe 'Invoke-WithRetry output and HTTP handling' { BeforeAll { $RepoRoot = Split-Path -Path (Split-Path -Path $ModuleRoot -Parent) -Parent . (Join-Path -Path $RepoRoot -ChildPath 'tests/Helpers/HttpTestDoubles.ps1') } BeforeEach { Mock -ModuleName tcs.core Start-Sleep { } } It 'Keeps a collection written as one object' { $result = Invoke-WithRetry -ScriptBlock { , @(1) } , $result | Should -BeOfType [object[]] $result.Count | Should -Be 1 $result[0] | Should -Be 1 } It 'Keeps several objects and writes nothing for no output' { $result = Invoke-WithRetry -ScriptBlock { 1; 2; 3 } $result | Should -Be @(1, 2, 3) @(Invoke-WithRetry -ScriptBlock { }).Count | Should -Be 0 } It 'Discards the output of failed attempts' { $script:tries = 0 $result = Invoke-WithRetry -DelaySeconds 0 -ScriptBlock { $script:tries++; "try $script:tries"; if ($script:tries -lt 2) { throw 'again' } } $result | Should -Be 'try 2' } It 'Does not retry non-terminating errors by default' { # The script block runs in this scope, so pin the preference (GitHub's pwsh shell sets Stop) $ErrorActionPreference = 'Continue' $script:tries = 0 $null = Invoke-WithRetry -DelaySeconds 0 -ScriptBlock { $script:tries++; Write-Error 'not terminating' } 2>$null $script:tries | Should -Be 1 } It 'Retries non-terminating errors with -RetryOnNonTerminatingError' { $script:tries = 0 $result = Invoke-WithRetry -DelaySeconds 0 -RetryOnNonTerminatingError -ScriptBlock { $script:tries++ if ($script:tries -lt 3) { Write-Error 'not yet' } try { throw 'handled' } catch { } , @('done') } -ErrorVariable errs $script:tries | Should -Be 3 , $result | Should -BeOfType [object[]] $result[0] | Should -Be 'done' } It 'Does not count native stderr lines as errors with -RetryOnNonTerminatingError' { # Windows PowerShell 5.1 turns redirected stderr into a terminating error under 'Stop' $ErrorActionPreference = 'Continue' $shell = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName $script:tries = 0 $output = @(Invoke-WithRetry -DelaySeconds 0 -RetryOnNonTerminatingError -ScriptBlock { $script:tries++ & $shell -NoProfile -NonInteractive -Command "[Console]::Error.WriteLine('stderr line')" 'done' } 2>$null) $script:tries | Should -Be 1 $output | Should -Be @('done') } It 'Throws the last non-terminating error when every attempt fails' { { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 1 -RetryOnNonTerminatingError -ScriptBlock { Write-Error 'always' } } | Should -Throw 'always' } It 'Retries a listed status code and throws others at once (Windows PowerShell error shape)' { $script:tries = 0 { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 2 -RetryOnStatusCode 503 -ScriptBlock { $script:tries++; throw (New-TestWebException -StatusCode 503) } } | Should -Throw $script:tries | Should -Be 3 $script:tries = 0 { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 2 -RetryOnStatusCode 503 -ScriptBlock { $script:tries++; throw (New-TestWebException -StatusCode 404) } } | Should -Throw $script:tries | Should -Be 1 } It 'Retries a listed status code and throws others at once (PowerShell 7 error shape)' -Skip:($PSVersionTable.PSEdition -eq 'Desktop') { $script:tries = 0 $result = Invoke-WithRetry -DelaySeconds 0 -RetryOnStatusCode 429 -ScriptBlock { $script:tries++ if ($script:tries -lt 2) { throw (New-TestHttpResponseException -StatusCode 429) } 'ok' } $result | Should -Be 'ok' $script:tries = 0 { Invoke-WithRetry -DelaySeconds 0 -RetryOnStatusCode 429 -ScriptBlock { $script:tries++; throw (New-TestHttpResponseException -StatusCode 400) } } | Should -Throw $script:tries | Should -Be 1 } It 'Still retries errors without an HTTP response when status codes are listed' { $script:tries = 0 { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 1 -RetryOnStatusCode 503 -ScriptBlock { $script:tries++; throw [System.Net.Sockets.SocketException]::new() } } | Should -Throw $script:tries | Should -Be 2 } It 'Honours Retry-After in seconds' { { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 1 -ScriptBlock { throw (New-TestWebException -StatusCode 429 -RetryAfter '7') } } | Should -Throw Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 7000 } } It 'Honours Retry-After as an HTTP date' { $date = [datetime]::UtcNow.AddSeconds(30).ToString('r', [System.Globalization.CultureInfo]::InvariantCulture) { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 1 -ScriptBlock { throw (New-TestWebException -StatusCode 503 -RetryAfter $date) } } | Should -Throw Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -gt 25000 -and $Milliseconds -le 30000 } } It 'Honours Retry-After on PowerShell 7 and caps it at MaxDelaySeconds' -Skip:($PSVersionTable.PSEdition -eq 'Desktop') { { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 1 -MaxDelaySeconds 5 -ScriptBlock { throw (New-TestHttpResponseException -StatusCode 429 -RetryAfter ([timespan]::FromSeconds(60))) } } | Should -Throw Should -Invoke -ModuleName tcs.core Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 5000 } } It 'Asks -ShouldRetry with the error record and attempt number' { $script:seen = @() { Invoke-WithRetry -DelaySeconds 0 -MaxRetries 5 -ShouldRetry { param($errorRecord, $attempt) $script:seen += "$attempt $($errorRecord.Exception.Message)"; $attempt -lt 2 } -ScriptBlock { throw 'nope' } } | Should -Throw 'nope' $script:seen | Should -Be @('1 nope', '2 nope') } It 'Adds at most JitterPercent to the delay' { { Invoke-WithRetry -DelaySeconds 1 -MaxRetries 3 -JitterPercent 50 -ScriptBlock { throw 'x' } } | Should -Throw Should -Invoke -ModuleName tcs.core Start-Sleep -Times 3 -Exactly -ParameterFilter { $Milliseconds -ge 1000 -and $Milliseconds -le 1500 } } } |