What Happens to Memoized Results When the Original Function Returns an Error?
Memoized functions permanently cache errors just like successful results, returning the same error on every subsequent call without re-executing the original function.
When using the MemoizeFunc utility from the aperturerobotics/util repository, understanding how errors are handled is critical for production reliability. The memoization mechanism in memo/memo.go stores both return values and errors from the original function, meaning that a single failure becomes the permanent result for all future invocations. This article examines the source code implementation to explain exactly how errors are captured and why they are never retried.
How MemoizeFunc Caches Errors
The MemoizeFunc implementation uses a closure to store the results of the first execution. According to the source code in memo/memo.go, the function declares private variables result and doneErr that capture whatever the wrapped function returns—whether a valid value, an error, or both.
The implementation follows this pattern:
func MemoizeFunc[T any](fn func() (T, error)) func() (T, error) {
var started atomic.Bool
done := make(chan struct{})
var result T
var doneErr error
return func() (T, error) {
if !started.Swap(true) { // first call
defer close(done) // signal completion
result, doneErr = fn() // run original function once
return result, doneErr
}
<-done // wait for first call to finish
return result, doneErr // return cached values (including error)
}
}
Key observations from the aperturerobotics/util source:
- First execution: The original function
fn()runs exactly once, and both its return value and error are stored inresultanddoneErr. - Synchronization: The
donechannel closes immediately afterfn()completes, unblocking any concurrent callers waiting for the result. - Subsequent calls: All later invocations bypass the original function entirely, returning the cached
resultanddoneErrdirectly.
Permanent Error Storage
Because the closure captures the error value in doneErr during the initial execution, that error becomes immutable for the lifetime of the memoized function. The MemoizeFunc does not distinguish between successful results and failures—it caches both with identical persistence. Consequently, if the original function returns an error, that error is not retried; it becomes the permanent result of the memoized function.
Practical Example: Memoizing a Flaky Function
Consider a function that fails deterministically. The following example demonstrates how MemoizeFunc handles the error:
package main
import (
"errors"
"fmt"
"time"
"github.com/aperturerobotics/util/memo"
)
// Simulate a function that always fails
func flaky() (int, error) {
fmt.Println("flaky() invoked")
return 0, errors.New("temporary failure")
}
func main() {
memoized := memo.MemoizeFunc(flaky)
// First call – executes flaky() and caches the error
_, err := memoized()
fmt.Println("first call error:", err) // => temporary failure
// Second call – returns cached error without executing flaky()
_, err = memoized()
fmt.Println("second call error:", err) // => temporary failure
// Even after waiting, the error remains cached
time.Sleep(100 * time.Millisecond)
_, err = memoized()
fmt.Println("after wait error:", err) // => temporary failure
}
Output:
flaky() invoked
first call error: temporary failure
second call error: temporary failure
after wait error: temporary failure
Note that flaky() prints exactly once, confirming that the original function never re-executes despite the error. The unit tests in memo/memo_test.go verify the caching behavior for successful results, and the same mechanism applies identically to error returns.
Summary
- Errors are cached permanently:
MemoizeFuncstores the error from the first execution in the closure'sdoneErrvariable. - No retry mechanism: The original function executes exactly once regardless of whether it returns an error or a value.
- Thread-safe propagation: The
donechannel ensures that all concurrent and subsequent callers receive the same cached error after the first call completes. - Source location: This behavior is implemented in
memo/memo.gowithin theaperturerobotics/utilrepository.
Frequently Asked Questions
Does MemoizeFunc retry the original function if it returns an error?
No. MemoizeFunc treats errors as valid return values and caches them permanently. Once the original function returns an error, that error is stored in the closure and returned for all subsequent calls without re-executing the function. This design ensures that expensive or side-effect-producing functions run at most once.
How does MemoizeFunc handle concurrent calls while the first execution is still running?
Concurrent calls block on the <-done channel receive operation. Once the first call completes and closes the done channel, all waiting goroutines wake up and receive the same cached result and error. This ensures that the original function executes exactly once even under concurrent access, as confirmed by the synchronization logic in memo/memo.go.
Can I clear or reset the memoized error to retry the function?
The current implementation in aperturerobotics/util does not provide a reset mechanism. The memoized function maintains its cached state for its entire lifetime. To retry a failed function, you must create a new memoized instance by calling MemoizeFunc again with the original function.
Where is the error caching logic implemented in the source code?
The error caching logic resides in memo/memo.go in the aperturerobotics/util repository. The relevant variables are doneErr (which stores the error) and result (which stores the return value), both captured in the closure returned by MemoizeFunc. The test suite in memo/memo_test.go validates the caching behavior for various execution scenarios.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →