From e12ab58d138b336ce6a2f1995f14d3b760fadaac Mon Sep 17 00:00:00 2001 From: Justin Wilson Date: Sat, 22 Aug 2026 22:32:50 -0500 Subject: [PATCH] fix(openai): raise after unstructured 422 retry exhaustion With drop_params enabled, the retry loops in completion, acompletion, and async_streaming swallowed openai.UnprocessableEntityError on every attempt and fell off the end after two identical 422s, implicitly returning None. The proxy then answered 200 with body null and spend logs, failure callbacks, and router fallbacks never fired. Only drop params on the first attempt; re-raise the error on the last so callers see the 422. Fixes #32221 --- litellm/llms/openai/openai.py | 14 ++- .../test_openai_422_retry_exhaustion.py | 97 +++++++++++++++++++ 2 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_openai_422_retry_exhaustion.py diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index ee0efb88a38..2120a21dbd9 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -649,7 +649,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): if custom_llm_provider is not None and custom_llm_provider != "openai": model_response.model = f"{custom_llm_provider}/{model}" - for _ in range(2): # if call fails due to alternating messages, retry with reformatted message + for attempt in range(2): # if call fails due to alternating messages, retry with reformatted message try: max_retries = inference_params.pop("max_retries", 2) if acompletion is True: @@ -778,11 +778,10 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): return final_response_obj except openai.UnprocessableEntityError as e: ## check if body contains unprocessable params - related issue https://github.com/BerriAI/litellm/issues/4800 - if litellm.drop_params is True or drop_params is True: + if attempt == 0 and (litellm.drop_params is True or drop_params is True): inference_params = drop_params_from_unprocessable_entity_error(e, inference_params) else: raise e - # e.message except Exception as e: if print_verbose is not None: print_verbose(f"openai.py: Received openai error - {e}") @@ -858,7 +857,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): litellm_params=litellm_params, headers=headers or {}, ) - for _ in range(2): # if call fails due to alternating messages, retry with reformatted message + for attempt in range(2): # if call fails due to alternating messages, retry with reformatted message try: openai_aclient: AsyncOpenAI = self._get_openai_client( is_async=True, @@ -930,11 +929,10 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): return final_response_obj except openai.UnprocessableEntityError as e: ## check if body contains unprocessable params - related issue https://github.com/BerriAI/litellm/issues/4800 - if litellm.drop_params is True or drop_params is True: + if attempt == 0 and (litellm.drop_params is True or drop_params is True): data = drop_params_from_unprocessable_entity_error(e, data) else: raise e - # e.message except Exception as e: exception_response = getattr(e, "response", None) status_code = getattr(e, "status_code", 500) @@ -1038,7 +1036,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): ) data["stream"] = True data.update(self.get_stream_options(stream_options=stream_options, api_base=api_base)) - for _ in range(2): + for attempt in range(2): try: openai_aclient: AsyncOpenAI = self._get_openai_client( is_async=True, @@ -1081,7 +1079,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): return streamwrapper except openai.UnprocessableEntityError as e: ## check if body contains unprocessable params - related issue https://github.com/BerriAI/litellm/issues/4800 - if litellm.drop_params is True or drop_params is True: + if attempt == 0 and (litellm.drop_params is True or drop_params is True): data = drop_params_from_unprocessable_entity_error(e, data) else: raise e diff --git a/tests/test_litellm/llms/openai/test_openai_422_retry_exhaustion.py b/tests/test_litellm/llms/openai/test_openai_422_retry_exhaustion.py new file mode 100644 index 00000000000..d46a8148f00 --- /dev/null +++ b/tests/test_litellm/llms/openai/test_openai_422_retry_exhaustion.py @@ -0,0 +1,97 @@ +""" +Regression tests for issue #32221: with drop_params=True, two consecutive 422s +with an unstructured body must raise instead of silently returning None. +""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import openai +import pytest + +from litellm.llms.openai.common_utils import OpenAIError +from litellm.llms.openai.openai import OpenAIChatCompletion, OpenAIConfig +from litellm.types.utils import ModelResponse + + +def _unstructured_422_error() -> openai.UnprocessableEntityError: + return openai.UnprocessableEntityError( + message="Error code: 422 - content moderation rejected the request", + response=httpx.Response( + 422, + request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"), + json={"error": {"message": "content moderation rejected the request"}}, + ), + body={"message": "content moderation rejected the request"}, + ) + + +@pytest.mark.asyncio +async def test_acompletion_raises_after_unstructured_422_retry_exhaustion(): + mock_client = MagicMock() + mock_client.chat.completions.with_raw_response.create = AsyncMock(side_effect=_unstructured_422_error()) + + with pytest.raises((openai.UnprocessableEntityError, OpenAIError)) as exc_info: + await OpenAIChatCompletion().acompletion( + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + provider_config=OpenAIConfig(), + model="gpt-4o", + model_response=ModelResponse(), + logging_obj=MagicMock(), + timeout=30.0, + api_key="sk-test", + client=mock_client, + drop_params=True, + ) + + assert exc_info.value.status_code == 422 + assert mock_client.chat.completions.with_raw_response.create.await_count == 2 + + +def test_completion_raises_after_unstructured_422_retry_exhaustion(): + mock_client = MagicMock(spec=openai.OpenAI) + mock_client.api_key = "sk-test" + mock_client._base_url = MagicMock() + mock_client.chat.completions.with_raw_response.create.side_effect = _unstructured_422_error() + + with pytest.raises((openai.UnprocessableEntityError, OpenAIError)) as exc_info: + OpenAIChatCompletion().completion( + model_response=ModelResponse(), + timeout=30.0, + optional_params={}, + litellm_params={}, + logging_obj=MagicMock(), + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + api_key="sk-test", + client=mock_client, + drop_params=True, + ) + + assert exc_info.value.status_code == 422 + assert mock_client.chat.completions.with_raw_response.create.call_count == 2 + + +@pytest.mark.asyncio +async def test_async_streaming_raises_after_unstructured_422_retry_exhaustion(): + mock_client = MagicMock() + mock_client.chat.completions.with_raw_response.create = AsyncMock(side_effect=_unstructured_422_error()) + + with pytest.raises((openai.UnprocessableEntityError, OpenAIError)) as exc_info: + await OpenAIChatCompletion().async_streaming( + timeout=30.0, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + provider_config=OpenAIConfig(), + model="gpt-4o", + logging_obj=MagicMock(), + api_key="sk-test", + client=mock_client, + drop_params=True, + ) + + assert exc_info.value.status_code == 422 + assert mock_client.chat.completions.with_raw_response.create.await_count == 2