From 20043aec3fd6026b0184a8987185ddfc949ab074 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 23 Sep 2026 17:11:30 +0000 Subject: [PATCH] fix(langfuse): url-encode prompt names and fetch cold prompts without client retries A cold get_prompt runs inline on the event loop; the generated v4 client's default two retries slept through Retry-After (up to 60 s per attempt) and held the loop. The wrapper also passed the raw name into api/public/v2/prompts/{name}, so 'what?' fetched prompt 'what' and folder names left the route. Quote the name with safe='' like the v4 SDK's own get_prompt and pass max_retries=0 like the projects.get calls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/langfuse/langfuse_sdk.py | 5 +- .../langfuse/test_langfuse_sdk.py | 76 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py index a43ab560f9c..b07482a8c6b 100644 --- a/litellm/integrations/langfuse/langfuse_sdk.py +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -16,6 +16,7 @@ from itertools import chain from time import monotonic, sleep from types import MappingProxyType from typing import Final, Literal +from urllib.parse import quote import httpx import opentelemetry.trace as otel_trace @@ -1129,7 +1130,9 @@ class LangfuseApiClient: def _fetch(self, key: _PromptKey) -> PromptClient: name, version, label = key - fetched: Final = _prompt_client(self.api.prompts.get(name, version=version, label=label)) + fetched: Final = _prompt_client( + self.api.prompts.get(quote(name, safe=""), version=version, label=label, request_options=_NO_REST_RETRIES) + ) with self._lock: self._prompts[key] = _CachedPrompt(prompt=fetched, fetched_at=monotonic()) return fetched diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py index 765b844820e..85cae9be6fb 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py @@ -20,6 +20,7 @@ import opentelemetry.trace as otel_trace import pytest from langfuse import LangfuseOtelSpanAttributes as A from langfuse.api.core.api_error import ApiError +from langfuse.api.core.request_options import RequestOptions from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest from opentelemetry.sdk.trace import SpanProcessor, TracerProvider from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult @@ -1064,6 +1065,74 @@ def test_auth_check_and_project_id_make_one_round_trip_when_langfuse_is_down(sta assert monotonic() - started < 0.5 +@pytest.mark.parametrize("status", [500, 503, 429], ids=["http-500", "http-503", "http-429"]) +def test_cold_prompt_miss_makes_one_round_trip_when_langfuse_is_down(status: int): + """A cold ``get_prompt`` fetches inline on the event loop; with the generated client's default retries a + 429 carrying ``Retry-After: 30`` used to hold the loop for a minute.""" + requests: list[httpx.Request] = [] + + def fail(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(status, request=request, headers={"retry-after": "30"}, json={"message": "down"}) + + client = build_langfuse_client( + public_key="pk", + secret_key="sk", + base_url="http://127.0.0.1:1", + httpx_client=httpx.Client(transport=httpx.MockTransport(fail)), + ) + + started = monotonic() + with pytest.raises(ApiError): + client.get_prompt("greeting") + assert len(requests) == 1 + assert monotonic() - started < 0.5 + + +_TEXT_PROMPT_BODY: Final[dict[str, object]] = { + "type": "text", + "name": "n", + "version": 1, + "config": {}, + "labels": ["production"], + "tags": [], + "prompt": "hello", +} + + +@pytest.mark.parametrize( + ("name", "encoded"), + [ + ("what?", "what%3F"), + ("folder/greeting", "folder%2Fgreeting"), + ("my-prompt?label=staging", "my-prompt%3Flabel%3Dstaging"), + ("100% sure#1", "100%25%20sure%231"), + ], + ids=["question-mark", "folder-slash", "query-injection", "percent-space-hash"], +) +def test_prompt_name_is_url_encoded_into_the_request_path(name: str, encoded: str): + """The v2 client quoted the name before building the path and the v4 SDK's ``get_prompt`` does too; the + generated client alone puts the raw name into the URL, so ``what?`` fetched prompt ``what`` and + ``a/b`` left the prompts route.""" + requests: list[httpx.Request] = [] + + def record(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, request=request, json=_TEXT_PROMPT_BODY) + + client = build_langfuse_client( + public_key="pk", + secret_key="sk", + base_url="http://127.0.0.1:1", + httpx_client=httpx.Client(transport=httpx.MockTransport(record)), + ) + + client.get_prompt(name, label="staging") + + (request,) = requests + assert request.url.raw_path == f"/api/public/v2/prompts/{encoded}?label=staging".encode() + + def test_rest_client_reports_the_project_id_and_a_passing_auth_check(): requests: list[httpx.Request] = [] client = build_langfuse_client( @@ -1527,9 +1596,10 @@ class _RecordingPromptsApi: self.prompts = self self.requests: list[tuple[str, int | None, str | None]] = [] # mutable-ok: test-side call log - def get(self, name: str, *, version: int | None, label: str | None): + def get(self, name: str, *, version: int | None, label: str | None, request_options: RequestOptions): from langfuse.api import Prompt_Text + assert request_options.get("max_retries") == 0, "a prompt fetch must not sleep through the client's retries" self.requests.append((name, version, label)) return Prompt_Text( name=name, @@ -1549,9 +1619,9 @@ class _BlockingPromptsApi(_RecordingPromptsApi): self.release = threading.Event() self.fail_refresh = False - def get(self, name: str, *, version: int | None, label: str | None): + def get(self, name: str, *, version: int | None, label: str | None, request_options: RequestOptions): is_refresh = bool(self.requests) - prompt = super().get(name, version=version, label=label) + prompt = super().get(name, version=version, label=label, request_options=request_options) if is_refresh: assert self.release.wait(5), "refresh was never released" if self.fail_refresh: