From 5fdd4161d75f6dad82d6cc4d6b4ff56144dfb0a3 Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Wed, 19 Aug 2026 16:16:29 -0700 Subject: [PATCH] fix(observability): count a pool timeout the engine reports without a code Pool contention raises a typed prisma error carrying `code == "P2024"`, which the attribute check already handled. When the database itself is unreachable the engine reports the same P2024 as a raw `EngineRequestError` instead, with no `code` attribute and the code recorded only in the JSON body it was built from, so those went uncounted. Seen on a live proxy at connection limit 1 with Postgres paused. Prisma classifies both as P2024, so which layer surfaced it should not decide whether the counter moves. The match is on prisma's own `error_code` field rather than the message text, so an error that merely mentions the code cannot trip it, and a different engine code is rejected. The two cases stay distinguishable in the metrics that matter: saturation holds busy at max with waiters queued, while an unreachable database drops open connections instead. --- litellm/proxy/db/exception_handler.py | 19 +++++++- .../proxy/db/test_exception_handler.py | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index fdc16809278..1a2a945a2ab 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -1,3 +1,4 @@ +import re from collections.abc import Awaitable, Callable from typing import Any, Final, TypeVar @@ -9,6 +10,11 @@ from litellm.proxy._types import ( ) from litellm.secret_managers.main import str_to_bool +# The engine reports a pool timeout as a raw HTTP error whose body carries the +# code, with no `code` attribute to read. Matched on prisma's own JSON field so a +# message that merely mentions the code cannot trip it. +_P2024_IN_ENGINE_BODY: Final = re.compile(r'"error_code"\s*:\s*"P2024"') + # Bounds the __cause__/__context__ walk in is_database_service_unavailable_error_in_chain. # Real exception chains are a few links deep; the cap also makes the walk cycle-safe. _MAX_EXCEPTION_CHAIN_DEPTH: Final = 20 @@ -137,8 +143,19 @@ class PrismaDBExceptionHandler: The ``P####`` codes are prisma's own namespace and only prisma populates ``code`` with one, so no type check is needed. Skipping it also keeps this callable on the DB failure path when prisma itself is stubbed. + + Both shapes have to be read. Contention raises a typed prisma error + carrying the code, which the attribute check already handles. When the + database is unreachable the engine reports the same P2024 as a raw HTTP + error instead, with no ``code`` attribute and the code only in the JSON + body. Prisma classifies both as P2024, so which layer surfaced it should + not decide whether it is counted. The neighbouring gauges separate the + two cases: saturation holds ``busy`` at ``max`` with waiters queued, + while an unreachable database drops ``open`` instead. """ - return getattr(e, "code", None) == "P2024" + if getattr(e, "code", None) == "P2024": + return True + return bool(_P2024_IN_ENGINE_BODY.search(str(e))) @staticmethod def is_database_transport_error(e: Exception) -> bool: diff --git a/tests/test_litellm/proxy/db/test_exception_handler.py b/tests/test_litellm/proxy/db/test_exception_handler.py index a2f365e58c3..479e6612dc4 100644 --- a/tests/test_litellm/proxy/db/test_exception_handler.py +++ b/tests/test_litellm/proxy/db/test_exception_handler.py @@ -623,6 +623,49 @@ def test_only_p2024_counts_as_pool_exhaustion(other_error): assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(other_error) is False +def test_pool_exhaustion_is_detected_in_the_shape_the_engine_actually_raises(): + """Contention raises a typed prisma error carrying the code, and that path is + covered above. When the database itself is unreachable the engine reports the + same P2024 as a raw HTTP error instead, with no `code` attribute at all and + the code recorded only in the JSON body. Prisma classifies both as P2024, so + the counter should not depend on which layer surfaced it. Body captured + verbatim from a live proxy run at connection limit 1 with Postgres paused.""" + from types import SimpleNamespace + + from prisma.engine.errors import EngineRequestError + + body = ( + '{"is_panic":false,"message":"Timed out fetching a new connection from the connection pool. ' + 'More info: http://pris.ly/d/connection-pool (Current connection pool timeout: 2, connection limit: 1)",' + '"meta":{"connection_limit":1,"timeout":2},"error_code":"P2024"}' + ) + error = EngineRequestError(response=SimpleNamespace(status=500), body=body) + + assert not hasattr(error, "code"), "the shape under test is the one with no code attribute" + assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(error) is True + + +def test_another_engine_error_code_is_not_read_as_pool_exhaustion(): + """The body is matched on prisma's own error_code field, so a different + engine failure carrying its own code must not be counted.""" + from types import SimpleNamespace + + from prisma.engine.errors import EngineRequestError + + error = EngineRequestError( + response=SimpleNamespace(status=500), + body='{"is_panic":false,"message":"boom","error_code":"P2010"}', + ) + + assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(error) is False + + +def test_a_message_merely_mentioning_the_code_is_not_pool_exhaustion(): + """Matching loose text would let any error whose message quotes P2024 inflate + the exhaustion counter.""" + assert PrismaDBExceptionHandler.is_connection_pool_timeout_error(Exception("see P2024 docs")) is False + + def test_pool_exhaustion_is_not_mistaken_for_a_reachability_failure(): """P2024's message contains "Timed out", which the transport classifier keyword-matches. The pool predicate must not inherit that ambiguity."""