Skip to content

fix(client): resolve project id once per resource manager, never raise from get_trace_url - #1813

Open
hchittanuru3 wants to merge 3 commits into
langfuse:mainfrom
hchittanuru3:fix/project-id-resolve-once
Open

fix(client): resolve project id once per resource manager, never raise from get_trace_url#1813
hchittanuru3 wants to merge 3 commits into
langfuse:mainfrom
hchittanuru3:fix/project-id-resolve-once

Conversation

@hchittanuru3

@hchittanuru3 hchittanuru3 commented Aug 13, 2026

Copy link
Copy Markdown

What does this PR do?

Langfuse._get_project_id() cached only a successful lookup, on an object that does not survive the call:

def _get_project_id(self) -> Optional[str]:
    if not self._project_id:
        proj = self.api.projects.get()
        if not proj.data or not proj.data[0].id:
            return None          # <- negative outcome never cached
        self._project_id = proj.data[0].id
    return self._project_id

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 new Langfuse on every call (get_client.py:_create_client_from_instance); LangfuseResourceManager is the singleton, the Langfuse wrapper is not. So get_client().get_trace_url(trace_id=row.trace_id) gets a fresh, empty cache every row and issues one blocking projects.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, or AttributeError from the api property when the client was built without keys) and it propagates out of get_trace_url into the caller. That's inconsistent with the rest of the client, where accessors return None and log. _finalize_experiment_result already wraps its _get_project_id() call in try/except Exception: pass for 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 None a request in flight is about to fill in.

_at_fork_reinit replaces 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_id checks _resources is None rather than letting the blanket except swallow AttributeError("Langfuse client is not initialized") — a client built without keys returns None quietly instead of logging a warning that blames project id resolution for a missing client.

Notes for review

  • Tradeoff: a transient failure now disables URL generation until the resource manager is recreated, rather than recovering on a later call. That seemed the right side to err on given the cost of the current behavior, and the warning says so. Happy to cache only the "no project found" case and leave exceptions retryable if you'd prefer.
  • Out of scope, flagging it: get_trace_url has no if not self._tracing_enabled guard, unlike get_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_url returns None in exactly the cases it did before, plus the cases where it used to raise.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactor
  • Documentation update
  • Tooling, CI, or repo maintenance

Verification

uv run --frozen ruff check .
uv run --frozen ruff format --check .
uv run --frozen mypy langfuse --no-error-summary
uv run --frozen pytest -n auto --dist worksteal tests/unit

tests/unit goes from 667 to 675 passed with the 8 cases in tests/unit/test_trace_url.py. Each one was checked against the commit before the behavior it covers, and fails there:

Test Covers
test_project_id_is_fetched_once_on_success success is resolved once
test_project_id_is_not_refetched_when_no_project_is_found empty project list is not retried
test_project_id_lookup_failure_does_not_raise_and_is_not_retried a raising lookup returns None, once
test_concurrent_callers_all_see_the_resolved_project_id 8 threads against a lookup held open on an Event
test_project_id_is_shared_across_client_instances repeated get_client() share one lookup
test_a_failed_lookup_is_not_retried_by_a_later_client_instance the negative outcome outlives the instance
test_fork_replaces_the_project_id_lock the lock is replaced before the _shutdown return
test_no_project_id_lookup_without_a_trace_id no lookup when there is no trace id

The 18 pre-existing tests/unit/test_prompt.py errors and the tests/unit/test_media.py formatting diff reproduce unchanged on main and are untouched here.

Checklist

  • I self-reviewed the diff using code_review.md.
  • I added or updated tests for behavior changes.
  • I updated docs, examples, or .env.template if needed.
  • I did not hand-edit generated files; if generated files changed, I used the upstream regeneration path.
  • I did not commit secrets or credentials.

…_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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@CLAassistant

CLAassistant commented Aug 13, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment thread langfuse/_client/client.py Outdated
if self._project_id_resolved:
return self._project_id

self._project_id_resolved = True

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.

P1 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

hchittanuru3 and others added 2 commits August 13, 2026 15:29
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>
@hchittanuru3 hchittanuru3 changed the title fix(client): resolve project id at most once and never raise from get_trace_url fix(client): resolve project id once per resource manager, never raise from get_trace_url Aug 13, 2026
@hchittanuru3

Copy link
Copy Markdown
Author

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. get_client() constructs a new Langfuse on every call, so a per-instance cache never survives the usage this PR is motivated by: one trace link per row of a list view still meant one blocking projects.get() per row, and the negative cache in particular was gone by the next row. The original _project_id had the same problem, so the earlier commits made the retry cheaper without actually removing it for get_client() users.

The resolution now lives on LangfuseResourceManager, which the class docstring already names as the owner of "retrieving and caching project information for URL generation and media handling" — the code had drifted from that. The singleton is keyed by public key, which is the granularity a project id has.

Two smaller things came with it:

  • _at_fork_reinit replaces the project id lock, right next to the class lock it already replaces, with the same reasoning: a parent thread holding it across the lookup at fork time leaves the child with a mutex nobody owns. Putting the state on the manager is what makes this reachable at all — an instance-level lock on Langfuse had no fork handler to live in.
  • Langfuse._get_project_id checks _resources is None instead of letting the blanket except swallow AttributeError("Langfuse client is not initialized"). A client built without keys now returns None quietly rather than logging a warning that blames project id resolution for a missing client.

Three new tests cover the move, each verified to fail against the previous commit. One question left open in the description: get_trace_url has no tracing_enabled guard, unlike its siblings, so a client with tracing disabled still pays the blocking lookup. I left that alone because skipping it changes observable behavior — say the word and I'll add it here, or open it separately.

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