Skip to content

fix(caching): bound the semantic cache embedding lookup so a dead embedding endpoint can't block requests - #37742

Merged
mateo-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_lit5879_semantic_cache_embedding_timeout
Aug 21, 2026
Merged

fix(caching): bound the semantic cache embedding lookup so a dead embedding endpoint can't block requests#37742
mateo-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_lit5879_semantic_cache_embedding_timeout

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

The semantic cache embedding lookup runs inline before the LLM call and had no deadline of its own, so it inherited the 6000s request timeout and the router's retries. When the configured embedding endpoint stops answering, every chat request parks in that lookup for minutes and the client often times out first. The failure is also silent: the only outward signs are the latency and x-litellm-semantic-similarity: 0.0

This gives the lookup its own 5s deadline, and a single attempt on the routed path, so an unreachable embedding endpoint costs a few seconds and a cache miss instead of the whole request. Redis, Valkey and qdrant semantic caches all get the bound, and it is tunable per cache config or with SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS

Measured on a live proxy against a real Anthropic model: 685.7s before, 7.4s after, same config.

User Flow

Before

Every chat completion through the gateway hangs for minutes, and most clients time out before an answer arrives

  1. The operator boots the proxy with semantic caching on and checks it is up: GET http://litellm-domain/health/liveliness returns "I'm alive!"
  2. They confirm the cache is wired: GET http://litellm-domain/cache/ping returns {"status": "healthy", "cache_type": "redis-semantic"}
  3. The embedding endpoint named by redis_semantic_cache_embedding_model stops answering. Nothing in the proxy says so, and GET /cache/ping still answers "status": "healthy" because for a semantic cache it does not test the embedding endpoint at all
  4. A user sends an ordinary request: POST http://litellm-domain/v1/chat/completions with {"model": "claude", "messages": [{"role": "user", "content": "What is the capital of France? Answer in one word."}]}. Nothing comes back
  5. The response lands 685 seconds later, carrying x-litellm-response-duration-ms: 685519.593, x-litellm-overhead-duration-ms: 683421.74 and x-litellm-semantic-similarity: 0.0. The answer itself is a normal "Paris", so the 11 minutes bought nothing
  6. When the embedding endpoint accepts connections and then goes quiet instead of refusing them, no response arrives at all. curl gives up with curl: (28) Operation timed out after 700002 milliseconds with 0 bytes received
  7. The only way out is editing config.yaml to remove the cache block and restarting the proxy. The same request then returns in 5.2 seconds

After

The same request comes back in about seven seconds, with the cache skipped instead of waited on

  1. Same boot, same GET http://litellm-domain/health/liveliness returning "I'm alive!"
  2. Same GET http://litellm-domain/cache/ping returning {"status": "healthy", "cache_type": "redis-semantic"}
  3. The embedding endpoint stops answering exactly as before
  4. The user sends the identical POST http://litellm-domain/v1/chat/completions
  5. The response lands in 7.4 seconds with x-litellm-response-duration-ms: 7204.558, x-litellm-overhead-duration-ms: 5021.482 and x-litellm-semantic-similarity: 0.0. The five seconds of overhead is the cache giving up, and the similarity of 0.0 is how the caller can tell the cache was skipped
  6. The endpoint that goes quiet rather than refusing behaves the same way now. The wait is the same five seconds, and the completion arrives
  7. No config edit and no restart. If the embedding endpoint is legitimately slower than five seconds, the operator raises semantic_cache_embedding_timeout under cache_params or sets SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS

Relevant issues

Linear ticket

Resolves LIT-5879

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Both runs use the same config file, the same redis, the same real Anthropic model, and the same prompt. The only difference is the commit the proxy runs from.

Shared setup, run once:

docker run -d --name lit5879-redis -p 16279:6379 redis/redis-stack-server:latest

cat > cache.yaml <<'YAML'
model_list:
  - model_name: claude
    litellm_params:
      model: anthropic/claude-sonnet-5
      api_key: os.environ/ANTHROPIC_API_KEY
  - model_name: cache-embedding
    litellm_params:
      model: openai/text-embedding-3-small
      api_base: "http://10.255.255.1:9/v1"
      api_key: dead-key

litellm_settings:
  cache: true
  cache_params:
    type: redis-semantic
    similarity_threshold: 0.8
    redis_semantic_cache_embedding_model: cache-embedding
    redis_url: "redis://127.0.0.1:16279"

general_settings:
  master_key: sk-lit5879-repro
YAML

10.255.255.1:9 is an unroutable address, so every connection to the embedding endpoint blackholes. The request below is the same one in both runs:

curl -sS -m 700 -w '\nWALL_SECONDS=%{time_total}\n' -D headers.txt -o body.json \
  http://127.0.0.1:$PORT/v1/chat/completions \
  -H 'Authorization: Bearer sk-lit5879-repro' \
  -H 'Content-Type: application/json' \
  -d '{"model":"claude","messages":[{"role":"user","content":"What is the capital of France? Answer in one word."}],"max_tokens":64}'

Before (e07a712)

  1. Boot the proxy on the base commit and send the request. It returns, eventually:
WALL_SECONDS=685.7

HTTP/1.1 200 OK
x-litellm-response-duration-ms: 685519.593
x-litellm-overhead-duration-ms: 683421.74
x-litellm-semantic-similarity: 0.0
x-litellm-attempted-retries: 0

{"choices":[{"message":{"content":"**Paris**", ...}}], ...}
  1. Subtracting the two duration headers puts the model at 2097.9ms, so 683.4 of the 685.7 seconds were spent inside the proxy before it ever dispatched. A direct Anthropic call with no proxy answers the same prompt in 2.52s
  2. The proxy debug log shows why: litellm.aembedding(... api_base='http://10.255.255.1:9/v1', ... timeout=6000.0) and Router._aembedding ..., num_retries - 2. The lookup inherits the request timeout and gets retried
  3. With an embedding endpoint that accepts connections and never answers rather than blackholing them, nothing times out at all. The same request produced curl: (28) at a 700s cap with no response at all, and the proxy log had zero hits for api.anthropic.com

After (7d23d41)

  1. Boot the proxy on this branch with the identical cache.yaml and send the identical request, twice:
WALL_SECONDS=7.4

HTTP/1.1 200 OK
x-litellm-response-duration-ms: 7204.558
x-litellm-overhead-duration-ms: 5021.482
x-litellm-semantic-similarity: 0.0
x-litellm-attempted-retries: 0

{"choices":[{"message":{"content":"**Paris**", ...}}], ...}
WALL_SECONDS=8.0
x-litellm-response-duration-ms: 7433.985
x-litellm-overhead-duration-ms: 5041.333
x-litellm-semantic-similarity: 0.0
  1. Both overheads land within 42ms of the 5000ms deadline, so the deadline is what ends the wait. Model time works out to 2183.1ms and 2392.7ms, matching the control
  2. The debug log now shows the embedding call carrying 'semantic-cache-embedding': True, timeout=5.0 and Router._aembedding ..., num_retries - 0, where the run above logged timeout=6000.0 and num_retries - 2
  3. grep -c 'api.anthropic.com' over the proxy log returns 2, one dispatch per request, against 0 for the hanging-endpoint case before the fix

Overhead drops from 683421.74ms to 5021.482ms, a factor of 136, and the response is byte-identical apart from ids and timing.

Type

🐛 Bug Fix

Caveats (if any)

The default deadline is 5s. Raise it with semantic_cache_embedding_timeout under cache_params, or with the SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS env var, if your embedding endpoint is legitimately slower than that.

An embedding endpoint that answers slowly but correctly will now miss the cache instead of making the caller wait for it.

When the embedding model is not a Router deployment, the lookup goes straight through litellm.embedding, where the provider client still applies its own retries. A dead endpoint costs about 16s on that path rather than 5s. Each attempt is still bounded by the deadline, and forcing zero retries there would change retry behavior for every embedding call, not just cache lookups

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Changes the hot path in front of LLM calls: embedding timeouts and retries now fail open to a cache miss. Slow-but-valid embed endpoints will miss cache unless the new timeout is raised.

Overview
Semantic-cache lookups no longer inherit the long request timeout and router retries. A hung embedding endpoint now fails the cache lookup quickly (default 5s, one attempt) and the request continues to the LLM as a miss.

semantic_cache_embedding_timeout (or SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS) is applied on Redis, Valkey, and Qdrant backends. Embedding calls pass timeout and num_retries=0; async lookups are also wrapped in asyncio.wait_for. Tests cover the bound, hang fail-open, and config forwarding.

Reviewed by Cursor Bugbot for commit d57715b. Bugbot is set up for automated code reviews on this repo. Configure here.

A semantic cache lookup embeds the prompt before the request reaches the LLM,
and that embedding call carried no deadline of its own. It inherited the 6000s
request timeout and the Router's num_retries, so an embedding endpoint that is
down or unroutable parked every proxied request for minutes and gave back
nothing but x-litellm-semantic-similarity 0.0 once it finally gave up.

The lookup now runs on its own short deadline, 5s by default, with retries off
so failures cannot stack. Redis, Valkey and qdrant all pick it up, and the
deadline is settable per cache with semantic_cache_embedding_timeout or
globally with SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/caching/_embedding_router.py 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will degrade performance by 10.15%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 30 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_token_counter_multi_turn 531.3 µs 591.3 µs -10.15%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing litellm_lit5879_semantic_cache_embedding_timeout (d57715b) with litellm_internal_staging (e07a712)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (dad4c1a) during the generation of this report, so e07a712 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@mateo-berri
mateo-berri marked this pull request as ready for review August 21, 2026 01:09
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR bounds semantic-cache embedding lookups so an unavailable embedding endpoint degrades to a cache miss rather than indefinitely delaying an LLM request.

  • Adds a configurable semantic-cache embedding timeout with a five-second default.
  • Applies the timeout and zero Router retries across Redis, Valkey, and Qdrant semantic caches.
  • Adds async hard deadlines and regression coverage for unresponsive embedding endpoints and cache fail-open behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/caching/_embedding_router.py Centralizes resolution of explicit and default semantic-cache embedding timeouts.
litellm/caching/caching.py Accepts and forwards the configurable embedding timeout to all semantic-cache backends.
litellm/caching/redis_semantic_cache.py Applies embedding timeouts and an async hard deadline while preserving fail-open cache behavior.
litellm/caching/qdrant_semantic_cache.py Bounds synchronous provider attempts and wraps asynchronous embedding lookup in a hard deadline.
litellm/caching/valkey_semantic_cache.py Initializes the inherited semantic-cache embedding timeout consistently.
litellm/constants.py Defines the environment-configurable five-second default timeout.
tests/test_litellm/caching/test_redis_semantic_cache.py Covers timeout propagation, unresponsive endpoints, defaults, and fail-open behavior.
tests/test_litellm/caching/test_qdrant_semantic_cache.py Covers timeout propagation, default resolution, and asynchronous hard-deadline behavior.

Reviews (2): Last reviewed commit: "chore(constants): drop the redundant com..." | Re-trigger Greptile

Comment thread litellm/caching/redis_semantic_cache.py
Comment thread litellm/constants.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Qdrant timeout skips similarity signal
    • Wrapped the embedding lookup in async_get_cache to catch asyncio.TimeoutError, set semantic-similarity to 0.0 on the metadata, and return None so the cache-skip signal matches the Redis/Valkey backends, plus added a regression test mirroring the Redis fail-open coverage.

Create PR

Or push these changes by commenting:

@cursor push e5785e0c78
Preview (e5785e0c78)
diff --git a/litellm/caching/qdrant_semantic_cache.py b/litellm/caching/qdrant_semantic_cache.py
--- a/litellm/caching/qdrant_semantic_cache.py
+++ b/litellm/caching/qdrant_semantic_cache.py
@@ -425,7 +425,11 @@
         messages: Final = kwargs["messages"]
         prompt: Final = get_str_from_messages(messages)
 
-        embedding_response: Final = await self._get_async_embedding(prompt, metadata=kwargs.get("metadata"))
+        try:
+            embedding_response: Final = await self._get_async_embedding(prompt, metadata=kwargs.get("metadata"))
+        except asyncio.TimeoutError:
+            kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0
+            return None
 
         # get the embedding
         embedding: Final = embedding_response["data"][0]["embedding"]

diff --git a/tests/test_litellm/caching/test_qdrant_semantic_cache.py b/tests/test_litellm/caching/test_qdrant_semantic_cache.py
--- a/tests/test_litellm/caching/test_qdrant_semantic_cache.py
+++ b/tests/test_litellm/caching/test_qdrant_semantic_cache.py
@@ -1023,6 +1023,52 @@
     assert time.monotonic() - started < 1.0
 
 
+@pytest.mark.asyncio
+async def test_qdrant_async_get_cache_fails_open_when_embedding_hangs(monkeypatch):
+    import asyncio
+    import time
+
+    from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache
+
+    cache = QdrantSemanticCache.__new__(QdrantSemanticCache)
+    cache.embedding_model = "sem-embed"
+    cache.embedding_max_input_tokens = None
+    cache.embedding_timeout = 0.05
+    cache.similarity_threshold = 0.8
+    cache.qdrant_api_base = "http://test.qdrant.local"
+    cache.collection_name = "test_collection"
+    cache.headers = {"Content-Type": "application/json", "api-key": "test_key"}
+    cache.async_client = MagicMock()
+    cache.async_client.post = AsyncMock()
+
+    async def never_responds(**kwargs):
+        await asyncio.sleep(3)
+        return {"data": [{"embedding": [0.1, 0.2]}]}
+
+    router = MagicMock()
+    router.get_configured_token_limits.return_value = (None, None)
+    router.aembedding = never_responds
+    monkeypatch.setitem(
+        sys.modules,
+        "litellm.proxy.proxy_server",
+        _router_proxy_module(router, "sem-embed"),
+    )
+
+    metadata: dict = {}
+    started = time.monotonic()
+    result = await cache.async_get_cache(
+        key="test_key",
+        messages=[{"role": "user", "content": "What is the capital of France?"}],
+        metadata=metadata,
+    )
+    elapsed = time.monotonic() - started
+
+    assert result is None
+    assert metadata["semantic-similarity"] == 0.0
+    assert elapsed < 1.0
+    cache.async_client.post.assert_not_called()
+
+
 def test_qdrant_semantic_cache_defaults_embedding_timeout():
     from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache
     from litellm.constants import SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS

You can send follow-ups to the cloud agent here.

input=embedding_input,
cache={"no-store": True, "no-cache": True},
)
return await asyncio.wait_for(embedding_call, self.embedding_timeout)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Qdrant timeout skips similarity signal

Low Severity

The new asyncio.wait_for deadline raises TimeoutError when the embedding endpoint is dead, but async_get_cache does not catch it. Redis and Valkey set semantic-similarity to 0.0 on that path; Qdrant leaves it unset, so callers lose the cache-skip signal this change documents.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d57715b. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Qdrant already propagates embedding failures instead of failing open, so wait_for only bounds the wait. Making it fail open is a separate change

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit d57715b. Configure here.

@mateo-berri
mateo-berri merged commit 4e02e7e into litellm_internal_staging Aug 21, 2026
73 checks passed
@mateo-berri
mateo-berri deleted the litellm_lit5879_semantic_cache_embedding_timeout branch August 21, 2026 01:51
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.

2 participants