-
Vulnerability Name: Unexpected Error Type Returned with
LastErrorOnly
,Attempts(0)
, andWrapContextErrorWithLastError
-
Description: When using
retry-go
with infinite retry attempts (Attempts(0)
), context cancellation, and bothLastErrorOnly(true)
andWrapContextErrorWithLastError(true)
options, the error returned upon context cancellation might be the context cancellation error itself instead of the last error returned by the retried function. This contradicts the expected behavior ofLastErrorOnly(true)
which should always return only the last error from the retried function, regardless of context cancellation.Steps to trigger:
- Configure
retry-go
to use infinite retry attempts by settingAttempts(0)
. - Enable context wrapping with the last error by setting
WrapContextErrorWithLastError(true)
. - Enable last error only mode by setting
LastErrorOnly(true)
. - Provide a context that will be cancelled during the retry operation.
- Execute
retry.Do
orretry.DoWithData
with this configuration. - Cancel the context while the retry operation is in progress.
- Observe the returned error type. It will be the context cancellation error, not the last error from the retried function as might be expected with
LastErrorOnly(true)
.
- Configure
-
Impact: Applications relying on
retry-go
and usingLastErrorOnly(true)
in combination with infinite retries and context cancellation might experience unexpected error handling. They might be designed to specifically handle the last error from the retried function whenLastErrorOnly(true)
is set, but instead, they receive a context cancellation error. This can lead to incorrect application logic execution following a retry operation cancellation, potentially causing unexpected states or failures in dependent processes. -
Vulnerability Rank: high
-
Currently Implemented Mitigations: No specific mitigation is implemented for this behavior. The current implementation in
retry.go
prioritizes returning the context error within the infinite retry loop whenWrapContextErrorWithLastError(true)
is enabled, even ifLastErrorOnly(true)
is also set. -
Missing Mitigations: The expected behavior with
LastErrorOnly(true)
should be to always return the last error from the retried function if it failed, or nil if it succeeded. In the case of context cancellation with infinite retries andWrapContextErrorWithLastError(true)
, the library should ideally return an error that wraps both the context cancellation error and the last error from the retried function, but whenLastErrorOnly(true)
is set, it should unwrap this combined error and return only the last error from the retried function.To mitigate this, the logic in the
DoWithData
function within the infinite retry loop should be adjusted to respectLastErrorOnly(true)
even when a context cancellation occurs. IfLastErrorOnly(true)
is enabled, the function should ensure that the unwrapped last error from the retried function is returned, possibly wrapping the context error as a cause, but ensuring the primary returned error is the last error from the retried function. -
Preconditions:
retry-go
library is used withDo
orDoWithData
functions.- Options
Attempts(0)
,Context(ctx)
,LastErrorOnly(true)
, andWrapContextErrorWithLastError(true)
are used together. - The provided context
ctx
is cancelled during the retry operation.
-
Source Code Analysis:
// File: /code/retry.go func DoWithData[T any](retryableFunc RetryableFuncWithData[T], opts ...Option) (T, error) { // ... if config.attempts == 0 { // Infinite retry loop for { // ... select { case <-config.timer.After(delay(config, n, err)): case <-config.context.Done(): if config.wrapContextErrorWithLastError { return emptyT, Error{config.context.Err(), lastErr} // Context error returned here } return emptyT, config.context.Err() // Context error returned here } } } // ... if config.lastErrorOnly { return emptyT, errorLog.Unwrap() // Last error unwrapped here, but not in infinite loop case } return emptyT, errorLog }
In the
DoWithData
function, whenconfig.attempts == 0
, the context cancellation logic within theselect
statement directly returns the context error whenconfig.wrapContextErrorWithLastError
is true, or just the context error otherwise. It does not consider theconfig.lastErrorOnly
option in this path.For finite attempts (the
else
block ofif config.attempts == 0
), thelastErrorOnly
option is considered after the retry loop completes, by callingerrorLog.Unwrap()
. This logic is missing for the infinite retry case with context cancellation.Visualization:
[Infinite Retry Loop with Context] --> Context Cancelled? | Yes --> WrapContextErrorWithLastError? | | Yes --> Return Error{context.Err(), lastErr} <-- Returns Context Error, ignoring LastErrorOnly | | No --> Return context.Err() <-- Returns Context Error, ignoring LastErrorOnly | No --> Continue Retry [Finite Retry Loop] --> After Loop --> LastErrorOnly? | Yes --> Return errorLog.Unwrap() <-- Returns Last Error | No --> Return errorLog
-
Security Test Case:
// File: /code/retry_test.go func TestLastErrorOnlyWithInfiniteRetriesAndContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() testErr := errors.New("test error from retryable func") retrySum := 0 var returnedErr error returnedErr = Do( func() error { retrySum++ if retrySum == 2 { cancel() // Cancel context on second attempt } return testErr }, Attempts(0), Context(ctx), LastErrorOnly(true), WrapContextErrorWithLastError(true), ) assert.Error(t, returnedErr, "Expected an error to be returned") assert.NotEqual(t, context.Canceled, returnedErr, "Expected last error from retryable func, not context.Canceled when LastErrorOnly(true)") assert.Equal(t, testErr, returnedErr, "Expected to receive the last error from retryable function") }
To run this test case, add it to
/code/retry_test.go
and executego test ./code
. This test case will fail with the current implementation because it will assert that the returned error istestErr
, but the current implementation will returncontext.Canceled
(or an error wrappingcontext.Canceled
andtestErr
ifWrapContextErrorWithLastError
is false, and then unwrapped tocontext.Cancelled
due toLastErrorOnly(true)
). The assertionassert.NotEqual(t, context.Canceled, returnedErr, ...)
will fail, proving the vulnerability.