fix: keep the provider's error status and Retry-After for API callers

When a provider rate-limited a request with 429, API clients of /api/chat/completions, /api/v1/chat/completions and /api/v1/messages got HTTP 400, and every endpoint dropped the provider's Retry-After header. Clients that retry rate limits, such as the OpenAI SDK, gave up after the first attempt.

Direct API callers now get the provider's status (429, 500, 503) and message, and provider error responses carry its Retry-After and retry-after-ms headers on the chat completions, Responses, embeddings, Ollama, proxy and Anthropic Messages endpoints. Error bodies are unchanged, an unknown model still returns 400, and session-based requests are untouched. An unreachable provider now returns 500 on these endpoints, the same as /openai/chat/completions already did.

Only the two retry headers are forwarded, because passing every upstream header through on an error would also hand the provider's cookies and auth challenges to the client.

Verified against a mock provider: the OpenAI SDK with max_retries=2 now retries a 429 and waits the Retry-After interval between attempts.

Fixes #31326

Co-authored-by: Juan Calderon-Perez <835733+gaby@users.noreply.github.com>
This commit is contained in:
Classic298 2026-09-24 19:12:43 +02:00
parent a87129eb61
commit f3bfd4a7f2
4 changed files with 38 additions and 16 deletions

View file

@ -247,7 +247,7 @@ from open_webui.utils.middleware import (
process_chat_payload,
process_chat_response,
)
from open_webui.utils.misc import get_response_error_detail, merge_model_params
from open_webui.utils.misc import get_response_error_detail, get_retry_after_headers, merge_model_params
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.models import (
check_model_access,
@ -1660,7 +1660,11 @@ async def chat_completion(
# raise so the except-block below emits a terminal
# chat:message:error, unblocking the frontend.
if isinstance(response, JSONResponse) and response.status_code >= 400:
raise Exception(get_response_error_detail(response))
raise HTTPException(
status_code=response.status_code,
detail=get_response_error_detail(response),
headers=get_retry_after_headers(response.headers),
)
if ctx is None:
ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events)
@ -1711,6 +1715,8 @@ async def chat_completion(
pass
# Legacy/direct callers await this response; returning None would send `null`. #23924
if not (metadata.get('session_id') and metadata.get('chat_id')):
if isinstance(e, HTTPException):
raise
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_detail,
@ -1979,9 +1985,10 @@ async def passthrough_anthropic_messages(request: Request, form_data: dict, user
requested_model=requested_model,
upstream_error=response_data,
)
retry_after_headers = get_retry_after_headers(response.headers)
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=response.status, content=response_data)
return Response(status_code=response.status, content=response_data)
return JSONResponse(status_code=response.status, content=response_data, headers=retry_after_headers)
return Response(status_code=response.status, content=response_data, headers=retry_after_headers)
return response_data
except HTTPException:

View file

@ -38,7 +38,7 @@ from open_webui.utils.access_control import check_model_access
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import calculate_sha256
from open_webui.utils.misc import calculate_sha256, get_retry_after_headers
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
@ -149,7 +149,11 @@ async def send_request(
upstream_error=res,
)
if 'error' in res:
raise HTTPException(status_code=r.status, detail=res['error'])
raise HTTPException(
status_code=r.status,
detail=res['error'],
headers=get_retry_after_headers(r.headers),
)
except HTTPException:
raise
except Exception as e:
@ -164,6 +168,7 @@ async def send_request(
raise HTTPException(
status_code=r.status,
detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR,
headers=get_retry_after_headers(r.headers),
)
r.raise_for_status()

View file

@ -44,7 +44,7 @@ from open_webui.utils.anthropic import ANTHROPIC_VERSION, get_anthropic_models,
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import convert_logit_bias_input_to_json
from open_webui.utils.misc import convert_logit_bias_input_to_json, get_retry_after_headers
from open_webui.utils.model_ids import strip_provider_model_prefix
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
@ -1679,6 +1679,7 @@ async def generate_chat_completion(
r.status,
error_body[:1000],
)
retry_after_headers = get_retry_after_headers(r.headers)
try:
error_json = JSONCodec.loads(error_body)
await publish_model_provider_request_failed(
@ -1691,7 +1692,7 @@ async def generate_chat_completion(
requested_model=requested_model,
upstream_error=error_json,
)
return JSONResponse(status_code=r.status, content=error_json)
return JSONResponse(status_code=r.status, content=error_json, headers=retry_after_headers)
except JSONCodec.JSONDecodeError:
await publish_model_provider_request_failed(
request,
@ -1706,6 +1707,7 @@ async def generate_chat_completion(
return JSONResponse(
status_code=r.status,
content={'error': {'message': error_body, 'code': r.status}},
headers=retry_after_headers,
)
streaming = True
@ -1732,10 +1734,11 @@ async def generate_chat_completion(
requested_model=requested_model,
upstream_error=response,
)
retry_after_headers = get_retry_after_headers(r.headers)
if isinstance(response, (dict, list)):
return JSONResponse(status_code=r.status, content=response)
return JSONResponse(status_code=r.status, content=response, headers=retry_after_headers)
else:
return PlainTextResponse(status_code=r.status, content=response)
return PlainTextResponse(status_code=r.status, content=response, headers=retry_after_headers)
# Convert Responses API result to simple format
if is_responses and isinstance(response, dict):
@ -1843,10 +1846,11 @@ async def embeddings(request: Request, form_data: dict, user):
requested_model=requested_model,
upstream_error=response_data,
)
retry_after_headers = get_retry_after_headers(r.headers)
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=r.status, content=response_data)
return JSONResponse(status_code=r.status, content=response_data, headers=retry_after_headers)
else:
return PlainTextResponse(status_code=r.status, content=response_data)
return PlainTextResponse(status_code=r.status, content=response_data, headers=retry_after_headers)
return response_data
except Exception as e:
@ -1971,10 +1975,11 @@ async def responses(
requested_model=payload.get('model'),
upstream_error=response_data,
)
retry_after_headers = get_retry_after_headers(r.headers)
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=r.status, content=response_data)
return JSONResponse(status_code=r.status, content=response_data, headers=retry_after_headers)
else:
return PlainTextResponse(status_code=r.status, content=response_data)
return PlainTextResponse(status_code=r.status, content=response_data, headers=retry_after_headers)
return response_data
@ -2093,10 +2098,11 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
requested_model=model_id,
upstream_error=response_data,
)
retry_after_headers = get_retry_after_headers(r.headers)
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=r.status, content=response_data)
return JSONResponse(status_code=r.status, content=response_data, headers=retry_after_headers)
else:
return PlainTextResponse(status_code=r.status, content=response_data)
return PlainTextResponse(status_code=r.status, content=response_data, headers=retry_after_headers)
return response_data

View file

@ -65,6 +65,10 @@ def get_response_error_detail(response: object) -> str:
return detail if isinstance(detail, str) else str(detail)
def get_retry_after_headers(headers: collections.abc.Mapping) -> dict[str, str]:
return {name: headers[name] for name in ('Retry-After', 'retry-after-ms') if name in headers}
def _strip_filter_entry(entry):
# Compose list-form env syntax passes surrounding quotes through verbatim
return (entry or '').strip().strip('"\'').strip()