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 in result and doneErr.
  • Synchronization: The done channel closes immediately after fn() completes, unblocking any concurrent callers waiting for the result.
  • Subsequent calls: All later invocations bypass the original function entirely, returning the cached result and doneErr directly.

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: MemoizeFunc stores the error from the first execution in the closure's doneErr variable.
  • No retry mechanism: The original function executes exactly once regardless of whether it returns an error or a value.
  • Thread-safe propagation: The done channel 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.go within the aperturerobotics/util repository.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →