Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions langfuse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
.. include:: ../README.md
"""

from langfuse._utils.request import APIError, APIErrors
from langfuse.batch_evaluation import (
BatchEvaluationResult,
BatchEvaluationResumeToken,
Expand All @@ -57,6 +58,7 @@
EvaluatorStats,
MapperFunction,
)
from langfuse.errors import AuthError, LangfuseError
from langfuse.experiment import Evaluation, RegressionError, RunnerContext

from ._client import client as _client_module
Expand Down Expand Up @@ -99,8 +101,12 @@

__all__ = [
"Langfuse",
"LangfuseError",
"LangfuseMedia",
"LangfuseMediaReference",
"APIError",
"APIErrors",
"AuthError",
"get_client",
"observe",
"propagate_attributes",
Expand Down
12 changes: 9 additions & 3 deletions langfuse/_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,17 @@
Prompt_Text,
ScoreBody,
TraceBody,
UnauthorizedError,
)
from langfuse.api.core import ApiError
from langfuse.batch_evaluation import (
BatchEvaluationResult,
BatchEvaluationResumeToken,
BatchEvaluationRunner,
CompositeEvaluatorFunction,
MapperFunction,
)
from langfuse.errors import AuthError
from langfuse.experiment import (
Evaluation,
EvaluatorFunction,
Expand Down Expand Up @@ -3497,7 +3500,8 @@ def auth_check(self) -> bool:
"""Check if the provided credentials (public and secret key) are valid.

Raises:
Exception: If no projects were found for the provided credentials.
AuthError: If the API rejects the credentials (401) or no projects
were found for the provided credentials.

Note:
This method is blocking. It is discouraged to use it in production code.
Expand All @@ -3508,7 +3512,7 @@ def auth_check(self) -> bool:
"Auth check successful, found %s projects", len(projects.data)
)
if len(projects.data) == 0:
raise Exception(
raise AuthError(
"Auth check failed, no project found for the keys provided."
)
Comment on lines +3515 to 3517

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 Invalid credentials bypass AuthError When credentials are invalid, projects.get() raises the generated UnauthorizedError before this new AuthError branch runs. UnauthorizedError is not a LangfuseError, and the existing handler catches a different generated error class. Callers using the new hierarchy therefore cannot catch this authentication failure. Handle the 401 response as AuthError at this boundary.

Knowledge Base Used: API client and service surface

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/client.py
Line: 3512-3514

Comment:
**Invalid credentials bypass AuthError** When credentials are invalid, `projects.get()` raises the generated `UnauthorizedError` before this new `AuthError` branch runs. `UnauthorizedError` is not a `LangfuseError`, and the existing handler catches a different generated error class. Callers using the new hierarchy therefore cannot catch this authentication failure. Handle the 401 response as `AuthError` at this boundary.

**Knowledge Base Used:** [API client and service surface](https://app.greptile.com/personal-org-4986/-/custom-context/knowledge-base/langfuse/langfuse-python/-/docs/api-client-and-service-surface.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 — fixed in a57ebff, and it was subtler than the diff suggested: the generated Error class the handler caught is itself just the generic 400 subclass of the fern ApiError base, so UnauthorizedError (a sibling subclass) escaped the handler entirely and reached callers raw, skipping even the handle_fern_exception logging.

auth_check() now catches ApiError instead, raises a chained AuthError for UnauthorizedError, and re-raises everything else unchanged (so non-auth fern errors keep their exact types). Added tests for the 401 → AuthError mapping and for a NotFoundError propagating unchanged.

Note: the same except Error pattern exists at a few other call sites in client.py (score/trace-tag helpers). I left those untouched to keep this PR scoped to the auth boundary — happy to do a separate pass converting them if that's wanted.

return True
Expand All @@ -3519,8 +3523,10 @@ def auth_check(self) -> bool:
)
return False

except Error as e:
except ApiError as e:
handle_fern_exception(e)
if isinstance(e, UnauthorizedError):
raise AuthError(f"Auth check failed, invalid credentials: {e}") from e
raise e

def create_dataset(
Expand Down
5 changes: 2 additions & 3 deletions langfuse/_utils/parse_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
# fern api errors
from langfuse.api import (
AccessDeniedError,
Error,
MethodNotAllowedError,
NotFoundError,
ServiceUnavailableError,
Expand Down Expand Up @@ -44,7 +43,7 @@
}


def generate_error_message_fern(error: Error) -> str:
def generate_error_message_fern(error: ApiError) -> str:
if isinstance(error, AccessDeniedError):
return errorResponseByCode.get(403, defaultErrorResponse)
elif isinstance(error, MethodNotAllowedError):
Expand All @@ -66,7 +65,7 @@ def generate_error_message_fern(error: Error) -> str:
return defaultErrorResponse # type: ignore


def handle_fern_exception(exception: Error) -> None:
def handle_fern_exception(exception: ApiError) -> None:
logger.debug(exception)
error_message = generate_error_message_fern(exception)
logger.error(error_message)
Expand Down
5 changes: 3 additions & 2 deletions langfuse/_utils/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import httpx

from langfuse._utils.serializer import EventSerializer
from langfuse.errors import LangfuseError
from langfuse.logger import langfuse_logger as logger


Expand Down Expand Up @@ -115,7 +116,7 @@ def _process_response(
raise APIError(res.status_code, res.text)


class APIError(Exception):
class APIError(LangfuseError):
def __init__(self, status: Union[int, str], message: str, details: Any = None):
self.message = message
self.status = status
Expand All @@ -126,7 +127,7 @@ def __str__(self) -> str:
return msg.format(self.message, self.status, self.details)


class APIErrors(Exception):
class APIErrors(LangfuseError):
def __init__(self, errors: List[APIError]):
self.errors = errors

Expand Down
27 changes: 27 additions & 0 deletions langfuse/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Exception hierarchy for the Langfuse Python SDK.

All exceptions defined in hand-written SDK code derive from
:class:`LangfuseError`, so applications can catch every SDK-raised error with
a single ``except LangfuseError`` clause while fine-grained subclasses remain
available for callers that need to distinguish failure modes. Every class in
this hierarchy is also an ``Exception`` subclass, so existing
``except Exception`` handlers keep working.

Errors raised by the generated API client (``langfuse.api``) are not part of
this hierarchy; they derive from the generated ``langfuse.api.core.ApiError``
base instead.
"""

__all__ = ["AuthError", "LangfuseError"]


class LangfuseError(Exception):
"""Base class for all exceptions raised by the Langfuse SDK."""


class AuthError(LangfuseError):
"""Raised when authentication with the Langfuse API fails.

Example: ``Langfuse.auth_check()`` raises this when the configured
credentials are accepted but resolve to no project.
"""
3 changes: 2 additions & 1 deletion langfuse/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)

from langfuse.api import DatasetItem
from langfuse.errors import LangfuseError
from langfuse.logger import langfuse_logger as logger
from langfuse.types import ExperimentScoreType

Expand Down Expand Up @@ -1162,7 +1163,7 @@ def run_experiment(
)


class RegressionError(Exception):
class RegressionError(LangfuseError):
"""Raised by a user's ``experiment`` function to signal a CI gate failure.

Intended for use with the ``langfuse/experiment-action`` GitHub Action
Expand Down
98 changes: 98 additions & 0 deletions tests/unit/test_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Tests for the Langfuse SDK exception hierarchy."""

from types import SimpleNamespace
from unittest.mock import patch

import pytest

from langfuse import (
APIError,
APIErrors,
AuthError,
Langfuse,
LangfuseError,
RegressionError,
)


class TestExceptionHierarchy:
def test_all_sdk_errors_derive_from_langfuse_error(self):
for error_cls in (AuthError, APIError, APIErrors, RegressionError):
assert issubclass(error_cls, LangfuseError)
assert issubclass(error_cls, Exception)

def test_api_error_str_is_unchanged(self):
error = APIError(401, "Unauthorized", {"code": "invalid_api_key"})

assert str(error) == "Unauthorized (401): {'code': 'invalid_api_key'}"

def test_api_errors_str_is_unchanged(self):
error = APIErrors([APIError(400, "Bad request"), APIError(429, "Rate limited")])

assert (
str(error) == "[Langfuse] Bad request (400): None, Rate limited (429): None"
)


class TestAuthCheck:
def test_auth_check_raises_auth_error_when_no_projects(self):
client = Langfuse(public_key="test_pk", secret_key="test_sk")

with (
patch.object(
client.api.projects,
"get",
return_value=SimpleNamespace(data=[]),
),
pytest.raises(AuthError, match="no project found"),
):
client.auth_check()

def test_auth_check_error_is_catchable_as_langfuse_error(self):
client = Langfuse(public_key="test_pk", secret_key="test_sk")

with (
patch.object(
client.api.projects,
"get",
return_value=SimpleNamespace(data=[]),
),
pytest.raises(LangfuseError),
):
client.auth_check()

def test_auth_check_returns_true_when_projects_exist(self):
client = Langfuse(public_key="test_pk", secret_key="test_sk")

with patch.object(
client.api.projects,
"get",
return_value=SimpleNamespace(data=[SimpleNamespace(id="p1")]),
):
assert client.auth_check() is True

def test_auth_check_maps_fern_unauthorized_error_to_auth_error(self):
from langfuse.api import UnauthorizedError

client = Langfuse(public_key="test_pk", secret_key="test_sk")
fern_error = UnauthorizedError(body={"error": "Unauthorized"})

with (
patch.object(client.api.projects, "get", side_effect=fern_error),
pytest.raises(AuthError, match="invalid credentials") as excinfo,
):
client.auth_check()

assert isinstance(excinfo.value.__cause__, UnauthorizedError)

def test_auth_check_propagates_non_auth_fern_errors_unchanged(self):
from langfuse.api import NotFoundError

client = Langfuse(public_key="test_pk", secret_key="test_sk")
fern_error = NotFoundError(body={"error": "Not found"})

with (
patch.object(client.api.projects, "get", side_effect=fern_error),
pytest.raises(NotFoundError),
):
client.auth_check()