From 26abed5cc32a92375465a48d7f61fa42ce555a96 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 13 May 2026 15:57:02 -0700 Subject: [PATCH] feat(prometheus): rate-limit category/type labels + exception_class back-compat (follow-up to #27687) (#27706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(prometheus): add rate_limit_category and rate_limit_type labels Adds two new labels to litellm_proxy_failed_requests_metric so dashboards can split 429s by rate-limit source (vendor vs. litellm-internal) and by the dimension that was exceeded (requests/tokens/concurrent_requests/ budget/max_iterations) without parsing free-text error messages. Closes the Prometheus side of LIT-2718. The unified RateLimitError.category and .rate_limit_type fields landed in PR #27687 but were only surfaced on StandardLoggingPayload (custom-callback channel); this exposes them on the metric label set as well. Both labels are populated only when the underlying exception is a litellm.RateLimitError; non-rate-limit failures keep them empty. Co-authored-by: Mateo Wang * feat(prometheus): populate rate-limit labels + preserve exception_class back-compat Two coupled changes in the Prometheus integration: 1. async_post_call_failure_hook now extracts the new RateLimitError .category / .rate_limit_type fields (added in PR #27687) via a _extract_rate_limit_labels helper and forwards them through UserAPIKeyLabelValues onto litellm_proxy_failed_requests_metric. Empty for non-rate-limit failures. 2. _get_exception_class_name special-cases ProxyRateLimitError and keeps emitting 'HTTPException' for the exception_class label. Without this shim, ProxyRateLimitError (which multi-inherits from HTTPException + RateLimitError) would silently flip the label from 'HTTPException' (the historical value for proxy-side 429s) to 'ProxyRateLimitError', breaking existing dashboards / alerts that key off exception_class='HTTPException'. Distinguishing vendor vs. litellm 429s is now the job of the new rate_limit_category label. Co-authored-by: Mateo Wang * test(prometheus): cover rate-limit labels and exception_class back-compat Adds 19 tests across: - enum / label-list registration - _extract_rate_limit_labels for vendor RateLimitError, ProxyRateLimitError, non-rate-limit and None inputs (incl. parametrized over every RateLimitErrorCategory x RateLimitType combo) - _get_exception_class_name back-compat: ProxyRateLimitError keeps the legacy 'HTTPException' string while vendor RateLimitError keeps the historical 'Provider.ClassName' format - end-to-end through async_post_call_failure_hook with both ProxyRateLimitError and vendor RateLimitError, asserting both new labels populate and exception_class stays back-compat Co-authored-by: Mateo Wang * fix(prometheus): tolerate missing fastapi in lazy ProxyRateLimitError import Address greptile feedback: - async_post_call_failure_hook docstring: drop the stale labelnames listing and reference PrometheusMetricLabels.litellm_proxy_failed_requests_metric as the source of truth so the doc cannot drift from the actual labelset. - _get_exception_class_name: guard the lazy ProxyRateLimitError import with ImportError so router-side fallback callsites don't blow up in non-proxy installs that don't have fastapi (a transitive dep of proxy.common_utils.proxy_rate_limit_error). Behavior is unchanged when fastapi is available. Also fix the existing enterprise callback test that asserted the old labelset on litellm_proxy_failed_requests_metric — it now expects the new rate_limit_category / rate_limit_type labels populated for vendor 429s. --------- Co-authored-by: Cursor Agent Co-authored-by: Mateo Wang --- litellm/integrations/prometheus.py | 72 ++++- litellm/types/integrations/prometheus.py | 12 + .../test_prometheus_logging_callbacks.py | 2 + .../test_prometheus_rate_limit_labels.py | 290 ++++++++++++++++++ 4 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index f9b1c666439..c318b2a7f2b 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -1814,14 +1814,8 @@ class PrometheusLogger(CustomLogger): Proxy level tracking - failed client side requests - labelnames=[ - "end_user", - "hashed_api_key", - "api_key_alias", - REQUESTED_MODEL, - "team", - "team_alias", - ] + EXCEPTION_LABELS, + See :attr:`PrometheusMetricLabels.litellm_proxy_failed_requests_metric` + for the authoritative list of labels emitted on this metric. """ from litellm.litellm_core_utils.litellm_logging import ( StandardLoggingPayloadSetup, @@ -1844,6 +1838,9 @@ class PrometheusLogger(CustomLogger): model_id = _metadata.get("model_info", {}).get("id") or request_data.get( "model_info", {} ).get("id") + rate_limit_category, rate_limit_type = self._extract_rate_limit_labels( + original_exception + ) enum_values = UserAPIKeyLabelValues( end_user=user_api_key_dict.end_user_id, user=user_api_key_dict.user_id, @@ -1858,6 +1855,8 @@ class PrometheusLogger(CustomLogger): status_code=str(status_code), exception_status=str(status_code), exception_class=self._get_exception_class_name(original_exception), + rate_limit_category=rate_limit_category, + rate_limit_type=rate_limit_type, tags=_tags, route=user_api_key_dict.request_route, client_ip=_metadata.get("requester_ip_address"), @@ -2535,6 +2534,33 @@ class PrometheusLogger(CustomLogger): @staticmethod def _get_exception_class_name(exception: Exception) -> str: + # Back-compat: ProxyRateLimitError multi-inherits from + # fastapi.HTTPException + litellm.RateLimitError. Before the unified + # rate-limit error class landed, every proxy-side 429 surfaced on this + # label as the literal string "HTTPException", and existing dashboards + # / alerts (e.g. trho's HubSpot dashboard) key off that exact value. + # Renaming the class would silently break those queries, so we keep + # emitting "HTTPException" here. Callers that need to distinguish + # vendor vs. litellm rate limits should use the new + # ``rate_limit_category`` / ``rate_limit_type`` labels instead. + # + # The import is intentionally lazy + ImportError-tolerant: this method + # is also called from router-side fallback events + # (``log_success_fallback_event`` / ``log_failure_fallback_event``) + # which can run in non-proxy installs where ``fastapi`` (a transitive + # dep of ``proxy_rate_limit_error``) is not installed. + try: + from litellm.proxy.common_utils.proxy_rate_limit_error import ( + ProxyRateLimitError, + ) + except ImportError: + ProxyRateLimitError = None # type: ignore[assignment,misc] + + if ProxyRateLimitError is not None and isinstance( + exception, ProxyRateLimitError + ): + return "HTTPException" + exception_class_name = "" if hasattr(exception, "llm_provider"): exception_class_name = getattr(exception, "llm_provider") or "" @@ -2549,6 +2575,36 @@ class PrometheusLogger(CustomLogger): exception_class_name += exception.__class__.__name__ return exception_class_name + @staticmethod + def _extract_rate_limit_labels( + exception: Optional[Exception], + ) -> Tuple[Optional[str], Optional[str]]: + """ + Pull the unified ``category`` / ``rate_limit_type`` fields off any + :class:`litellm.RateLimitError` (vendor-side or litellm-internal) so + Prometheus can split 429s by source + dimension without the consumer + parsing free-text error messages. + + Returns ``(None, None)`` for non-rate-limit exceptions. Both values are + sanitized to plain ``str`` so str-enum subclasses don't leak their + repr into the label. + """ + if exception is None or not isinstance(exception, litellm.RateLimitError): + return None, None + + def _coerce(value: Any) -> Optional[str]: + if value is None: + return None + inner = getattr(value, "value", None) + if isinstance(inner, str): + return inner + return str(value) + + return ( + _coerce(getattr(exception, "category", None)), + _coerce(getattr(exception, "rate_limit_type", None)), + ) + async def log_success_fallback_event( self, original_model_group: str, kwargs: dict, original_exception: Exception ): diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index 43a287f29bc..3cd26a990b7 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -115,6 +115,8 @@ class ValidationResults: REQUESTED_MODEL = "requested_model" EXCEPTION_STATUS = "exception_status" EXCEPTION_CLASS = "exception_class" +RATE_LIMIT_CATEGORY = "rate_limit_category" +RATE_LIMIT_TYPE = "rate_limit_type" STATUS_CODE = "status_code" EXCEPTION_LABELS = [EXCEPTION_STATUS, EXCEPTION_CLASS] LATENCY_BUCKETS = ( @@ -173,6 +175,8 @@ class UserAPIKeyLabelNames(Enum): API_PROVIDER = "api_provider" EXCEPTION_STATUS = EXCEPTION_STATUS EXCEPTION_CLASS = EXCEPTION_CLASS + RATE_LIMIT_CATEGORY = RATE_LIMIT_CATEGORY + RATE_LIMIT_TYPE = RATE_LIMIT_TYPE STATUS_CODE = "status_code" FALLBACK_MODEL = "fallback_model" ROUTE = "route" @@ -334,6 +338,12 @@ class PrometheusMetricLabels: UserAPIKeyLabelNames.USER_EMAIL.value, UserAPIKeyLabelNames.EXCEPTION_STATUS.value, UserAPIKeyLabelNames.EXCEPTION_CLASS.value, + # Surfaced from RateLimitError.category / .rate_limit_type when the + # underlying exception is a rate-limit error; ``None`` otherwise. Lets + # dashboards split 429s into vendor vs. litellm and by exceeded + # dimension (RPM/TPM/concurrent/budget) without parsing error text. + UserAPIKeyLabelNames.RATE_LIMIT_CATEGORY.value, + UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value, UserAPIKeyLabelNames.ROUTE.value, UserAPIKeyLabelNames.CLIENT_IP.value, UserAPIKeyLabelNames.USER_AGENT.value, @@ -775,6 +785,8 @@ class UserAPIKeyLabelValues: api_provider: Optional[str] = None exception_status: Optional[str] = None exception_class: Optional[str] = None + rate_limit_category: Optional[str] = None + rate_limit_type: Optional[str] = None status_code: Optional[str] = None fallback_model: Optional[str] = None route: Optional[str] = None diff --git a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py index e6c76fa7cd6..c40d7da91de 100644 --- a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py +++ b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py @@ -825,6 +825,8 @@ async def test_async_post_call_failure_hook(prometheus_logger): requested_model="gpt-3.5-turbo", exception_status="429", exception_class="Openai.RateLimitError", + rate_limit_category="vendor_rate_limit", + rate_limit_type=None, route=user_api_key_dict.request_route, model_id=None, client_ip=None, diff --git a/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py b/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py new file mode 100644 index 00000000000..25030a11ff8 --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_rate_limit_labels.py @@ -0,0 +1,290 @@ +""" +Tests for the Prometheus rate-limit labels added on top of PR #27687. + +Covers two follow-up gaps to the unified rate-limit error work: + +1. ``litellm_proxy_failed_requests_metric`` now carries + ``rate_limit_category`` and ``rate_limit_type`` labels populated from + :class:`litellm.RateLimitError` (vendor + ``ProxyRateLimitError`` + subclass). Closes the Prometheus side of LIT-2718. +2. ``_get_exception_class_name`` keeps emitting the literal string + ``"HTTPException"`` for ``ProxyRateLimitError`` so existing dashboards + that key off ``exception_class="HTTPException"`` for litellm-internal + 429s don't silently break when the new class lands. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.exceptions import ( + RateLimitError, + RateLimitErrorCategory, + RateLimitType, +) +from litellm.integrations.prometheus import PrometheusLogger +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError +from litellm.types.integrations.prometheus import ( + PrometheusMetricLabels, + UserAPIKeyLabelNames, + UserAPIKeyLabelValues, +) + + +# --------------------------------------------------------------------------- +# Label / enum wiring +# --------------------------------------------------------------------------- + + +def test_should_register_rate_limit_label_names_on_enum(): + assert UserAPIKeyLabelNames.RATE_LIMIT_CATEGORY.value == "rate_limit_category" + assert UserAPIKeyLabelNames.RATE_LIMIT_TYPE.value == "rate_limit_type" + + +def test_should_include_rate_limit_labels_on_failed_requests_metric(): + labels = PrometheusMetricLabels.get_labels("litellm_proxy_failed_requests_metric") + assert "rate_limit_category" in labels + assert "rate_limit_type" in labels + # These must coexist with the legacy exception labels (back-compat). + assert "exception_class" in labels + assert "exception_status" in labels + + +def test_should_accept_rate_limit_fields_on_user_api_key_label_values(): + enum_values = UserAPIKeyLabelValues( + rate_limit_category="litellm_rate_limit", + rate_limit_type="requests", + ) + assert enum_values.rate_limit_category == "litellm_rate_limit" + assert enum_values.rate_limit_type == "requests" + + +# --------------------------------------------------------------------------- +# _extract_rate_limit_labels helper +# --------------------------------------------------------------------------- + + +def test_should_extract_vendor_category_for_vanilla_rate_limit_error(): + err = RateLimitError(message="vendor 429", llm_provider="openai", model="gpt-4o") + category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err) + assert category == "vendor_rate_limit" + assert rate_limit_type is None + + +def test_should_extract_litellm_category_and_type_for_proxy_rate_limit_error(): + err = ProxyRateLimitError( + detail={"error": "tpm exceeded"}, + category=RateLimitErrorCategory.LITELLM_RATE_LIMIT, + rate_limit_type=RateLimitType.TOKENS, + ) + category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err) + assert category == "litellm_rate_limit" + assert rate_limit_type == "tokens" + + +def test_should_return_none_for_non_rate_limit_exception(): + assert PrometheusLogger._extract_rate_limit_labels(ValueError("nope")) == ( + None, + None, + ) + + +def test_should_return_none_for_none_exception(): + assert PrometheusLogger._extract_rate_limit_labels(None) == (None, None) + + +@pytest.mark.parametrize( + "category_enum,rate_limit_enum,expected_category,expected_type", + [ + ( + RateLimitErrorCategory.LITELLM_RATE_LIMIT, + RateLimitType.REQUESTS, + "litellm_rate_limit", + "requests", + ), + ( + RateLimitErrorCategory.LITELLM_RATE_LIMIT, + RateLimitType.TOKENS, + "litellm_rate_limit", + "tokens", + ), + ( + RateLimitErrorCategory.LITELLM_RATE_LIMIT, + RateLimitType.CONCURRENT_REQUESTS, + "litellm_rate_limit", + "concurrent_requests", + ), + ( + RateLimitErrorCategory.LITELLM_RATE_LIMIT, + RateLimitType.BUDGET, + "litellm_rate_limit", + "budget", + ), + ( + RateLimitErrorCategory.LITELLM_RATE_LIMIT, + RateLimitType.MAX_ITERATIONS, + "litellm_rate_limit", + "max_iterations", + ), + ( + RateLimitErrorCategory.LITELLM_BATCH_RATE_LIMIT, + RateLimitType.REQUESTS, + "litellm_batch_rate_limit", + "requests", + ), + ], +) +def test_should_serialize_rate_limit_enums_as_underlying_string_values( + category_enum, rate_limit_enum, expected_category, expected_type +): + err = ProxyRateLimitError( + detail="boom", category=category_enum, rate_limit_type=rate_limit_enum + ) + category, rate_limit_type = PrometheusLogger._extract_rate_limit_labels(err) + assert category == expected_category + assert rate_limit_type == expected_type + + +# --------------------------------------------------------------------------- +# _get_exception_class_name back-compat +# --------------------------------------------------------------------------- + + +def test_should_emit_legacy_http_exception_label_for_proxy_rate_limit_error(): + """ + ``ProxyRateLimitError`` multi-inherits from ``HTTPException`` + + ``RateLimitError``. The ``exception_class`` label MUST keep emitting + "HTTPException" for back-compat with existing dashboards (see Slack + thread + PR #27687 review). Distinguishing vendor vs. litellm 429s + is now the job of the new ``rate_limit_category`` label. + """ + err = ProxyRateLimitError(detail={"error": "boom"}) + assert PrometheusLogger._get_exception_class_name(err) == "HTTPException" + + +def test_should_keep_provider_prefixed_exception_class_for_vendor_rate_limit_errors(): + err = RateLimitError(message="vendor 429", llm_provider="openai", model="gpt-4o") + # Vendor-side errors keep the historical "Provider.ClassName" formatting. + assert PrometheusLogger._get_exception_class_name(err) == "Openai.RateLimitError" + + +def test_should_preserve_exception_class_name_for_unrelated_exceptions(): + assert PrometheusLogger._get_exception_class_name(ValueError("nope")) == ( + "ValueError" + ) + + +# --------------------------------------------------------------------------- +# End-to-end wiring through async_post_call_failure_hook +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_should_populate_rate_limit_labels_for_proxy_rate_limit_error_on_failure_hook(): + """ + When a proxy hook raises ``ProxyRateLimitError`` and the failure flows + through ``async_post_call_failure_hook``, the resulting + ``UserAPIKeyLabelValues`` must carry both new labels AND keep + ``exception_class="HTTPException"`` for back-compat. + """ + with patch( + "litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None + ): + logger = PrometheusLogger() + logger.litellm_proxy_failed_requests_metric = MagicMock() + logger.litellm_proxy_total_requests_metric = MagicMock() + logger.get_labels_for_metric = MagicMock( + return_value=PrometheusMetricLabels.get_labels( + "litellm_proxy_failed_requests_metric" + ) + ) + + err = ProxyRateLimitError( + detail={"error": "rpm exceeded"}, + category=RateLimitErrorCategory.LITELLM_RATE_LIMIT, + rate_limit_type=RateLimitType.REQUESTS, + ) + + with patch( + "litellm.integrations.prometheus.prometheus_label_factory" + ) as mock_label_factory: + mock_label_factory.return_value = {} + await logger.async_post_call_failure_hook( + request_data={"model": "gpt-4o-mini", "metadata": {}}, + original_exception=err, + user_api_key_dict=UserAPIKeyAuth(token="t"), + ) + + enum_values = mock_label_factory.call_args_list[0].kwargs["enum_values"] + assert isinstance(enum_values, UserAPIKeyLabelValues) + assert enum_values.rate_limit_category == "litellm_rate_limit" + assert enum_values.rate_limit_type == "requests" + # Back-compat: exception_class on a ProxyRateLimitError stays "HTTPException". + assert enum_values.exception_class == "HTTPException" + assert enum_values.exception_status == "429" + + +@pytest.mark.asyncio +async def test_should_populate_rate_limit_labels_for_vendor_rate_limit_error_on_failure_hook(): + with patch( + "litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None + ): + logger = PrometheusLogger() + logger.litellm_proxy_failed_requests_metric = MagicMock() + logger.litellm_proxy_total_requests_metric = MagicMock() + logger.get_labels_for_metric = MagicMock( + return_value=PrometheusMetricLabels.get_labels( + "litellm_proxy_failed_requests_metric" + ) + ) + + err = RateLimitError(message="upstream 429", llm_provider="openai", model="gpt-4o") + + with patch( + "litellm.integrations.prometheus.prometheus_label_factory" + ) as mock_label_factory: + mock_label_factory.return_value = {} + await logger.async_post_call_failure_hook( + request_data={"model": "gpt-4o", "metadata": {}}, + original_exception=err, + user_api_key_dict=UserAPIKeyAuth(token="t"), + ) + + enum_values = mock_label_factory.call_args_list[0].kwargs["enum_values"] + assert isinstance(enum_values, UserAPIKeyLabelValues) + assert enum_values.rate_limit_category == "vendor_rate_limit" + assert enum_values.rate_limit_type is None + # Vendor errors keep the historical Provider.ClassName label. + assert enum_values.exception_class == "Openai.RateLimitError" + assert enum_values.exception_status == "429" + + +@pytest.mark.asyncio +async def test_should_leave_rate_limit_labels_blank_for_non_rate_limit_failure(): + with patch( + "litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None + ): + logger = PrometheusLogger() + logger.litellm_proxy_failed_requests_metric = MagicMock() + logger.litellm_proxy_total_requests_metric = MagicMock() + logger.get_labels_for_metric = MagicMock( + return_value=PrometheusMetricLabels.get_labels( + "litellm_proxy_failed_requests_metric" + ) + ) + + with patch( + "litellm.integrations.prometheus.prometheus_label_factory" + ) as mock_label_factory: + mock_label_factory.return_value = {} + await logger.async_post_call_failure_hook( + request_data={"model": "gpt-4o", "metadata": {}}, + original_exception=RuntimeError("boom"), + user_api_key_dict=UserAPIKeyAuth(token="t"), + ) + + enum_values = mock_label_factory.call_args_list[0].kwargs["enum_values"] + assert isinstance(enum_values, UserAPIKeyLabelValues) + assert enum_values.rate_limit_category is None + assert enum_values.rate_limit_type is None