fix(client): resolve project id once per resource manager, never raise from get_trace_url - #1813
fix(client): resolve project id once per resource manager, never raise from get_trace_url#1813hchittanuru3 wants to merge 3 commits into
Conversation
…_trace_url `_get_project_id` cached only a successful lookup. When the lookup failed -- an auth error, or no project for the configured keys -- `self._project_id` stayed `None`, so the next call issued the blocking request again. Callers that generate one URL per row (rendering trace links in a list view) turned that into one blocking HTTP request per row, per render. The lookup was also unguarded, so a transport or auth error propagated out of `get_trace_url` into the caller. Every other public accessor on the client returns `None` rather than raising, and URL generation is a convenience that should not be able to take down the code path that logs a link. A project id does not change for a given key pair, so the outcome is now resolved at most once per client instance, negative outcomes included, and failures are logged and swallowed. The tradeoff is that a transient failure disables URL generation for the life of the client; the warning says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| if self._project_id_resolved: | ||
| return self._project_id | ||
|
|
||
| self._project_id_resolved = True |
There was a problem hiding this comment.
Resolution flag exposes incomplete state
If two threads call get_trace_url on the same client during initial resolution, the first sets _project_id_resolved before completing the request, so the second reads the still-None project ID and silently omits a valid trace URL; an interleaving before the flag assignment can also issue the lookup more than once.
Knowledge Base Used: Client Core
Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/client.py
Line: 2429
Comment:
**Resolution flag exposes incomplete state**
If two threads call `get_trace_url` on the same client during initial resolution, the first sets `_project_id_resolved` before completing the request, so the second reads the still-`None` project ID and silently omits a valid trace URL; an interleaving before the flag assignment can also issue the lookup more than once.
**Knowledge Base Used:** [Client Core](https://app.greptile.com/personal-org-4986/-/custom-context/knowledge-base/langfuse/langfuse-python/-/docs/client-core.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Good catch, and it was a real regression rather than a pre-existing race: setting the flag before the request completed let a concurrent caller see it, read a still-None project id, and return no URL while the lookup that would have produced one was still in flight. The previous code merely duplicated the request, which was wasteful but correct.
Fixed in 6d24ddc: resolution happens under a lock and the flag is set only once the outcome is known, so a concurrent caller either sees fully resolved state or waits for the in-flight lookup. Added test_concurrent_callers_all_see_the_resolved_project_id, which runs 8 threads against a lookup held open on an Event and fails against the previous commit.
Chasing this also turned up something bigger, which I'm addressing in a follow-up commit: the state was on the wrong object. get_client() builds a new Langfuse on every call, so a per-instance cache never survives the per-row usage this PR is motivated by. It's moving to LangfuseResourceManager — which the class docstring already names as the owner of "retrieving and caching project information for URL generation" — where the singleton is keyed by public key and the existing _at_fork_reinit handler can reset the lock.
Addresses review feedback on the previous commit: setting the resolved flag before the request completed let a concurrent caller observe the pre-resolution state -- it saw the flag, read a still-`None` project id, and returned no URL while a lookup that would have produced one was in flight. The resolution now happens under a lock, and the flag is set only once the outcome is known. A concurrent caller either sees a fully resolved state or waits for the in-flight lookup. The fetch moves to `_fetch_project_id` so the locked section stays one assignment. `test_concurrent_callers_all_see_the_resolved_project_id` fails against the previous commit and passes here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cache was on the wrong object. `get_client()` builds a new `Langfuse` on
every call, so state memoized there does not outlive the call that created it:
`get_client().get_trace_url(trace_id=...)` per row still issued one blocking
`projects.get()` per row, which is the case the earlier commits set out to fix.
`LangfuseResourceManager` is the singleton, keyed by public key -- the exact
granularity a project id has -- and its class docstring already names it the
owner of "retrieving and caching project information for URL generation". The
resolution, its lock and the negative outcome move there; `Langfuse` delegates.
Two things fall out of the move:
- `_at_fork_reinit` now replaces the project id lock, next to the class lock it
already replaces for the same reason. A parent thread holding it across the
lookup at fork time would otherwise deadlock the first caller in the child.
- `Langfuse._get_project_id` checks `_resources` instead of letting the blanket
`except` swallow `AttributeError("Langfuse client is not initialized")`. A
client built without keys returns None quietly rather than logging a warning
that blames project id resolution for a missing client.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed b629c44, which widens the fix — worth calling out since it changes what the PR does, and the title/description are updated to match. Reviewing my own diff after the concurrency fix, I found the caching was on the wrong object. The resolution now lives on Two smaller things came with it:
Three new tests cover the move, each verified to fail against the previous commit. One question left open in the description: |
What does this PR do?
Langfuse._get_project_id()cached only a successful lookup, on an object that does not survive the call:Three problems follow, all of which surface through
get_trace_url, its only real caller. The motivating usage is rendering one trace link per row of a list view — we store the trace id alongside the record it handled and generate a URL per row.1. The cache is on the wrong object.
get_client()constructs a newLangfuseon every call (get_client.py:_create_client_from_instance);LangfuseResourceManageris the singleton, theLangfusewrapper is not. Soget_client().get_trace_url(trace_id=row.trace_id)gets a fresh, empty cache every row and issues one blockingprojects.get()per row.2. A failed lookup is retried on every call. When the keys have no project, or the request fails, nothing is memoized at all, so the retry happens even for a caller holding a long-lived client. A project id doesn't change for a key pair — there is nothing to gain by asking again.
3. The lookup is unguarded.
self.api.projects.get()can raise (transport error, 401, orAttributeErrorfrom theapiproperty when the client was built without keys) and it propagates out ofget_trace_urlinto the caller. That's inconsistent with the rest of the client, where accessors returnNoneand log._finalize_experiment_resultalready wraps its_get_project_id()call intry/except Exception: passfor exactly this reason; this moves the guard into the resolution itself so every caller gets it.Change
Resolution moves to
LangfuseResourceManager.get_project_id(), which is where the class docstring already says it belongs — "Retrieving and caching project information for URL generation and media handling" — and where the singleton is keyed by public key, the granularity a project id actually has.Langfuse._get_project_id()delegates.The outcome is resolved at most once per resource manager, negative outcomes included, under a lock with the resolved flag set only after the outcome is known, so a concurrent caller either sees resolved state or waits for the in-flight lookup rather than reading a
Nonea request in flight is about to fill in._at_fork_reinitreplaces the new lock alongside the class lock it already replaces, and for the same reason documented there: a parent thread holding it across the lookup at fork time would leave the child with a mutex nobody owns. The cached id survives the fork; only the mutex is swapped, so an interrupted lookup is simply retried.Langfuse._get_project_idchecks_resources is Nonerather than letting the blanketexceptswallowAttributeError("Langfuse client is not initialized")— a client built without keys returnsNonequietly instead of logging a warning that blames project id resolution for a missing client.Notes for review
get_trace_urlhas noif not self._tracing_enabledguard, unlikeget_current_trace_id/get_current_observation_id. A client with tracing explicitly disabled still pays the blocking lookup and returns a URL for a trace that was never exported. I left it alone since skipping it changes observable behavior — happy to add it here or open a separate issue.No public API change:
get_trace_urlreturnsNonein exactly the cases it did before, plus the cases where it used to raise.Type of change
Verification
tests/unitgoes from 667 to 675 passed with the 8 cases intests/unit/test_trace_url.py. Each one was checked against the commit before the behavior it covers, and fails there:test_project_id_is_fetched_once_on_successtest_project_id_is_not_refetched_when_no_project_is_foundtest_project_id_lookup_failure_does_not_raise_and_is_not_retriedNone, oncetest_concurrent_callers_all_see_the_resolved_project_idEventtest_project_id_is_shared_across_client_instancesget_client()share one lookuptest_a_failed_lookup_is_not_retried_by_a_later_client_instancetest_fork_replaces_the_project_id_lock_shutdownreturntest_no_project_id_lookup_without_a_trace_idThe 18 pre-existing
tests/unit/test_prompt.pyerrors and thetests/unit/test_media.pyformatting diff reproduce unchanged onmainand are untouched here.Checklist
code_review.md..env.templateif needed.