From 245da8d3ae0d605afcaf175e0f95820387509326 Mon Sep 17 00:00:00 2001 From: Yoel-qin <920503661@qq.com> Date: Tue, 29 Sep 2026 15:43:47 +0800 Subject: [PATCH 1/2] feat: add LangfuseError exception hierarchy Introduce a LangfuseError base class in langfuse/errors.py and derive all hand-written SDK exceptions from it: APIError, APIErrors, and RegressionError. Replace the last bare raise Exception in auth_check() with a new AuthError subclass. Export the hierarchy from the package root so applications can catch SDK errors precisely with 'except LangfuseError' while existing 'except Exception' handlers keep working unchanged. Closes #906 --- langfuse/__init__.py | 6 ++++ langfuse/_client/client.py | 5 +-- langfuse/_utils/request.py | 5 +-- langfuse/errors.py | 27 ++++++++++++++ langfuse/experiment.py | 3 +- tests/unit/test_errors.py | 72 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 langfuse/errors.py create mode 100644 tests/unit/test_errors.py diff --git a/langfuse/__init__.py b/langfuse/__init__.py index 6f2bfbb08..8d32a657f 100644 --- a/langfuse/__init__.py +++ b/langfuse/__init__.py @@ -49,6 +49,7 @@ .. include:: ../README.md """ +from langfuse._utils.request import APIError, APIErrors from langfuse.batch_evaluation import ( BatchEvaluationResult, BatchEvaluationResumeToken, @@ -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 @@ -99,8 +101,12 @@ __all__ = [ "Langfuse", + "LangfuseError", "LangfuseMedia", "LangfuseMediaReference", + "APIError", + "APIErrors", + "AuthError", "get_client", "observe", "propagate_attributes", diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 42d861fd4..a8388170c 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -123,6 +123,7 @@ CompositeEvaluatorFunction, MapperFunction, ) +from langfuse.errors import AuthError from langfuse.experiment import ( Evaluation, EvaluatorFunction, @@ -3497,7 +3498,7 @@ 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 no projects were found for the provided credentials. Note: This method is blocking. It is discouraged to use it in production code. @@ -3508,7 +3509,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." ) return True diff --git a/langfuse/_utils/request.py b/langfuse/_utils/request.py index 402d0b5a7..35a0409dc 100644 --- a/langfuse/_utils/request.py +++ b/langfuse/_utils/request.py @@ -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 @@ -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 @@ -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 diff --git a/langfuse/errors.py b/langfuse/errors.py new file mode 100644 index 000000000..27802482e --- /dev/null +++ b/langfuse/errors.py @@ -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. + """ diff --git a/langfuse/experiment.py b/langfuse/experiment.py index aa5481829..f38cf0178 100644 --- a/langfuse/experiment.py +++ b/langfuse/experiment.py @@ -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 @@ -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 diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py new file mode 100644 index 000000000..38fa74721 --- /dev/null +++ b/tests/unit/test_errors.py @@ -0,0 +1,72 @@ +"""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 From a57ebff24633a2cc2722593f9d7cdb5b80e90a72 Mon Sep 17 00:00:00 2001 From: Yoel-qin <920503661@qq.com> Date: Tue, 29 Sep 2026 15:59:06 +0800 Subject: [PATCH 2/2] fix: map 401 to AuthError in auth_check Invalid credentials never reached the AuthError branch: api.projects.get() raises the generated UnauthorizedError, which is not a subclass of the generated Error class that auth_check() caught, so it escaped the handler entirely. Catch the fern ApiError base instead, raise AuthError (chained) for UnauthorizedError, and re-raise everything else unchanged. Widen handle_fern_exception/generate_error_message_fern annotations to ApiError to match what they already handle at runtime. --- langfuse/_client/client.py | 9 +++++++-- langfuse/_utils/parse_error.py | 5 ++--- tests/unit/test_errors.py | 26 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index a8388170c..96c90819a 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -115,7 +115,9 @@ Prompt_Text, ScoreBody, TraceBody, + UnauthorizedError, ) +from langfuse.api.core import ApiError from langfuse.batch_evaluation import ( BatchEvaluationResult, BatchEvaluationResumeToken, @@ -3498,7 +3500,8 @@ def auth_check(self) -> bool: """Check if the provided credentials (public and secret key) are valid. Raises: - AuthError: 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. @@ -3520,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( diff --git a/langfuse/_utils/parse_error.py b/langfuse/_utils/parse_error.py index 2b9a7bd6f..70b4e17e3 100644 --- a/langfuse/_utils/parse_error.py +++ b/langfuse/_utils/parse_error.py @@ -6,7 +6,6 @@ # fern api errors from langfuse.api import ( AccessDeniedError, - Error, MethodNotAllowedError, NotFoundError, ServiceUnavailableError, @@ -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): @@ -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) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 38fa74721..ebb3128a3 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -70,3 +70,29 @@ def test_auth_check_returns_true_when_projects_exist(self): 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()