Skip to content

fix: prevent caching error responses in fetchWithCache#8070

Merged
mldangelo merged 3 commits intopromptfoo:mainfrom
aerosta:fix/cache-error-response-poisoning
Mar 10, 2026
Merged

fix: prevent caching error responses in fetchWithCache#8070
mldangelo merged 3 commits intopromptfoo:mainfrom
aerosta:fix/cache-error-response-poisoning

Conversation

@aerosta
Copy link
Contributor

@aerosta aerosta commented Mar 9, 2026

Summary

fetchWithCache caches HTTP 200 responses that contain an error body such as {"error": "rate limit exceeded"}.

That path is meant to skip caching, but it returns data, so cache.wrap() stores the error response and poisons the cache for up to 14 days.

The !response.ok branch already avoids this by setting errorResponse and returning undefined. This change makes the error-key path behave the same way.

Fix

  • store the response in errorResponse
  • return undefined instead of data

Tests

Local Node version is outside the repo's supported range, so final validation should come from CI.

@aerosta aerosta requested a review from a team as a code owner March 9, 2026 22:07
@aerosta aerosta requested a review from kkahadze March 9, 2026 22:07
Copy link
Contributor

@promptfoo-scanner promptfoo-scanner bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 All Clear

Reviewed the small change in src/cache.ts to avoid caching JSON responses that include an error field. Traced the fetch/cache path and confirmed no prompts, LLM tool capabilities, or execution sinks were modified. This PR narrows caching behavior and does not introduce any LLM security risks.

Minimum severity threshold: 🟡 Medium | To re-scan after changes, comment @promptfoo-scanner
Learn more


Was this helpful?  👍 Yes  |  👎 No 

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

The fetchWithCache function in src/cache.ts was modified to alter the handling of JSON responses containing an error key. Previously, such responses were returned directly. Now, when an error key is detected, the serialized data is assigned to errorResponse and the function returns early, preventing the error-containing response from being cached. The outer logic then uses this errorResponse variable for the final return value.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: preventing caching of error responses in the fetchWithCache function, which directly matches the changeset's primary objective.
Description check ✅ Passed The description is well-related to the changeset, providing clear context about the cache poisoning issue and explaining both the problem and the solution implemented.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cache.ts`:
- Around line 247-251: The branch in the fetch/caching flow uses closure-local
errorResponse to return non-cacheable JSON payloads, which fails under
cache.wrap() deduplication because concurrent waiters won't set that variable;
instead, make the producer signal the non-cacheable response by throwing a
specific sentinel (e.g., NonCacheableResponseError) when parsedData?.error is
detected in the block that currently sets errorResponse and returns, then catch
that sentinel around the cache.wrap() call and extract/return the payload to
callers; update the logger.debug message in the parsedData?.error branch and
ensure other errors still propagate normally.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c6dd7b2a-00fb-448f-bb5a-dc1855b2ff7b

📥 Commits

Reviewing files that changed from the base of the PR and between 5913b2f and cbfdb3f.

📒 Files selected for processing (1)
  • src/cache.ts

src/cache.ts Outdated
Comment on lines +247 to +251
// Don't cache if the parsed data contains an error
if (format === 'json' && parsedData?.error) {
logger.debug(`Not caching ${url} because it contains an 'error' key: ${parsedData.error}`);
return data;
errorResponse = data;
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does cache-manager's wrap()deduplicate concurrent calls for the same key, and if the wrapped function returnsundefined, do waiting callers receive that same undefined result?

💡 Result:

Yes—cache-manager’s wrap() deduplicates concurrent calls for the same key within the same process/instance: it wraps the whole “get-from-cache → maybe call fn → set” flow in coalesceAsync(${cacheId}::${key}, ...), so concurrent callers share the same in-flight Promise and only one execution of fnc() happens at a time for that key. [1]

If fnc() resolves to undefined, all waiting callers will receive that same undefined, because they’re awaiting the same coalesced Promise result. [1]

However, wrap() treats undefined as a cache miss (if (value === undefined) { const result = await fnc(); ... }), so even if the underlying Keyv store can technically persist an undefined payload, wrap() will not consider it a cached hit on later calls—subsequent wrap() calls will run fnc() again. [1][2]

Sources:
[1] cache-manager wrap() implementation (uses coalesceAsync, and checks value === undefined) https://github.com/jaredwray/cacheable/blob/main/packages/cache-manager/src/index.ts
[2] Keyv get() miss semantics (miss returns undefined) https://keyv.org/docs/keyv/


🏁 Script executed:

# Get context around the changed lines and the return statement at line 268
head -n 280 src/cache.ts | tail -n 50

Repository: promptfoo/promptfoo

Length of output: 1621


🏁 Script executed:

# Look for the fetchWithCache function and the cache.wrap call
rg -A 50 "async function fetchWithCache" src/cache.ts

Repository: promptfoo/promptfoo

Length of output: 1803


🏁 Script executed:

# Find the parseResult function to see if it handles null/undefined
rg -B 3 -A 10 "function parseResult|const parseResult" src/cache.ts

Repository: promptfoo/promptfoo

Length of output: 45


Avoid using errorResponse as a wrap() side channel.

Because cache.wrap() deduplicates concurrent calls for the same key, concurrent callers waiting on the same in-flight request do not execute the producer and thus never assign their own errorResponse. When this branch returns undefined, waiting callers reach line 268 with both cachedResponse and errorResponse unset, causing JSON.parse(null) to throw instead of returning the error payload to downstream code. Propagate the non-cacheable response through a shared mechanism (e.g., throw a sentinel caught around cache.wrap()) rather than via closure-local state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cache.ts` around lines 247 - 251, The branch in the fetch/caching flow
uses closure-local errorResponse to return non-cacheable JSON payloads, which
fails under cache.wrap() deduplication because concurrent waiters won't set that
variable; instead, make the producer signal the non-cacheable response by
throwing a specific sentinel (e.g., NonCacheableResponseError) when
parsedData?.error is detected in the block that currently sets errorResponse and
returns, then catch that sentinel around the cache.wrap() call and
extract/return the payload to callers; update the logger.debug message in the
parsedData?.error branch and ensure other errors still propagate normally.

@mldangelo mldangelo self-requested a review as a code owner March 10, 2026 04:39
@mldangelo
Copy link
Member

Thank you again @aerosta!

@mldangelo mldangelo merged commit 033e1bb into promptfoo:main Mar 10, 2026
31 checks passed
@aerosta
Copy link
Contributor Author

aerosta commented Mar 10, 2026

Thank you again @aerosta!

Thanks @mldangelo, happy to contribute.

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.

3 participants