mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
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>
This commit is contained in:
parent
8bbdda47ad
commit
20043aec3f
2 changed files with 77 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue