Skip to content

fix(cache): isolate retry-loop kwargs so cache store key matches lookup key - #2455

Closed
ErenAta16 wants to merge 3 commits into
567-labs:mainfrom
ErenAta16:fix/cache-key-retry-divergence
Closed

fix(cache): isolate retry-loop kwargs so cache store key matches lookup key#2455
ErenAta16 wants to merge 3 commits into
567-labs:mainfrom
ErenAta16:fix/cache-key-retry-divergence

Conversation

@ErenAta16

@ErenAta16 ErenAta16 commented Jul 17, 2026

Copy link
Copy Markdown

Fixes #2454.

What's broken

_create_sync_wrapper/_create_async_wrapper (instructor/v2/core/patch.py) compute a cache lookup key from new_kwargs["messages"] before calling retry_sync_v2/retry_async_v2, and recompute the store key from the same new_kwargs["messages"] after the retry call returns, but pass new_kwargs into the retry call by reference.

Each reask handler (reask_tools, reask_md_json, reask_default, reask_responses_tools in providers/openai/handlers.py, and Anthropic's handle_reask) does kwargs = kwargs.copy() then kwargs["messages"].append(...)/.extend(...). The shallow dict copy doesn't copy the messages list, so this mutates the exact list object patch.py's new_kwargs still points to.

Any request that needed at least one retry ends up with a store key computed from the post-retry, reask-polluted messages list, different from the lookup key computed from the pristine one before the retry loop ran. The result is correct, but it gets cached under a key nothing will ever look up again, so an identical follow-up request always misses and re-triggers a real LLM call.

Fix

Added isolate_retry_kwargs() in messages.py, right next to the existing copy_messages_for_mutation helper (added for #2417/#2428, which fixed the analogous problem one layer up in prepare_request). It returns kwargs with a shallow copy of whichever of messages/contents/chat_history is present, so mutations during the retry loop land on a private list and can never leak back into the kwargs dict patch.py reads again for the cache store key, regardless of what an individual reask handler does internally.

Used it at both call sites where new_kwargs is handed to the retry layer (sync and async).

Testing

Added test_auto_cache_prevents_duplicate_calls_after_a_retry in tests/cache/test_cache_integration.py, next to the existing test_auto_cache_prevents_duplicate_provider_calls. It forces exactly one retry (first fake response fails a Pydantic validator, second succeeds), then makes an identical second call and asserts the provider function was only invoked twice total (once for the first call's retry, zero more for the second).

Verified both directions:

# against the unfixed patch.py/messages.py (git stash)
FAILED tests/cache/test_cache_integration.py::test_auto_cache_prevents_duplicate_calls_after_a_retry
AssertionError: Second, identical call should hit the cache instead of calling the provider again
assert 3 == 2

# against the fix
tests/cache/test_cache_integration.py::test_auto_cache_prevents_duplicate_calls_after_a_retry PASSED

Also ran the broader cache/retry suites to check for regressions, all green:

tests/cache/ tests/coverage/test_cache_coverage.py tests/coverage/test_core_patch_retry_coverage.py tests/v2/test_retry_runtime.py
55 passed

Issue ticket number and link

Fixes #2454

Checklist before requesting a review

  • I have performed a self-review of my code
  • If it is a core feature, I have added thorough tests.
  • If it is a core feature, I have added documentation. (not applicable — internal bug fix, no user-facing API or behavior change to document)

patch.py computes a cache lookup key from new_kwargs["messages"] before
retry_sync_v2/retry_async_v2 run, and recomputes the store key from the
same new_kwargs["messages"] after they return, but passes new_kwargs into
the retry call by reference. Reask handlers (reask_tools, reask_md_json,
reask_default, reask_responses_tools, Anthropic's handle_reask) each do
kwargs = kwargs.copy() then kwargs["messages"].append(...)/.extend(...):
the shallow dict copy doesn't copy the messages list, so those handlers
mutate the same list object patch.py still holds a reference to. Any
request that needed at least one retry ends up with a store key computed
from the post-retry, reask-polluted messages list, different from the
lookup key computed from the pristine list, so the result gets cached
under a key nothing will ever look up again.

Add isolate_retry_kwargs() in messages.py, mirroring the existing
copy_messages_for_mutation helper (added for 567-labs#2417/567-labs#2428), and use it at
both call sites where new_kwargs is handed to the retry layer, so mutations
during the retry loop can never leak back into the kwargs dict used for the
cache store key regardless of what an individual reask handler does.

Adds a regression test, test_auto_cache_prevents_duplicate_calls_after_a_retry
in tests/cache/test_cache_integration.py, next to the existing
test_auto_cache_prevents_duplicate_provider_calls. Verified red (fails on
the unfixed code) and green (passes on the fixed code).

Fixes 567-labs#2454.
@ErenAta16 ErenAta16 changed the title Isolate retry-loop kwargs so cache store key matches lookup key fix(cache): isolate retry-loop kwargs so cache store key matches lookup key Jul 17, 2026
isolate_retry_kwargs stopped after copying the first of
messages/contents/chat_history it found, so a kwargs dict carrying
more than one would leave the others aliased to the caller's list.
No current provider does this, but nothing in the kwargs shape rules
it out either. Isolate each candidate independently instead.
@jxnl

jxnl commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Consolidated and shipped in #2495. Closing this focused patch as superseded; thank you for the contribution.

@jxnl jxnl closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cache= silently stops caching any request that needed a retry (lookup/store key divergence)

2 participants