[fix] plugin: repeat the request on 5xx as well

A server that is not ready does not always take its time about saying so.
Ollama answers 5xx immediately while a model is still being loaded, and a
gateway with no upstream yet does the same, so the failure arrives at
once rather than as a timeout.

Both mean "not right now" and are worth one more attempt.  4xx keeps
being final -- a wrong API key or an unknown model name is not going to
change between two requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty
2026-08-11 11:58:31 -07:00
co-authored by Claude Opus 5
parent 4a582c0a15
commit 0cc3748866
2 changed files with 40 additions and 6 deletions
+15 -5
View File
@@ -254,10 +254,13 @@ def _open_upstream(client: httpx.Client, payload: dict[str, t.Any]) -> tuple[t.A
"""Start the streaming completion on the LLM server. """Start the streaming completion on the LLM server.
Returns the (already entered) stream context and the response, or Returns the (already entered) stream context and the response, or
``(None, None)`` if no usable response was received. A server that does not ``(None, None)`` if no usable response was received.
answer in time is asked again (:py:obj:`UPSTREAM_RETRIES`); a server that
*did* answer, but with an error status, is not -- a wrong API key or an The request is repeated (:py:obj:`UPSTREAM_RETRIES`) when the server did not
unknown model name does not become right when asked twice.""" answer in time, and when it answered ``5xx`` -- both mean *not right now*,
and the most common reason is a model that is still being loaded. A ``4xx``
is not repeated: a wrong API key or an unknown model name does not become
right when asked twice."""
for attempt in range(UPSTREAM_RETRIES + 1): for attempt in range(UPSTREAM_RETRIES + 1):
stream_ctx = client.stream("POST", "/chat/completions", json=payload) stream_ctx = client.stream("POST", "/chat/completions", json=payload)
@@ -284,8 +287,15 @@ def _open_upstream(client: httpx.Client, payload: dict[str, t.Any]) -> tuple[t.A
detail = resp.text.strip()[:200] detail = resp.text.strip()[:200]
except (httpx.HTTPError, UnicodeDecodeError): # pragma: no cover except (httpx.HTTPError, UnicodeDecodeError): # pragma: no cover
pass pass
log.warning("LLM server responded with HTTP %s %s", resp.status_code, detail)
stream_ctx.__exit__(None, None, None) stream_ctx.__exit__(None, None, None)
# 5xx is the server saying it is not able to answer *right now* -- a
# model still loading, a gateway with no upstream yet. 4xx is the
# server saying the request is wrong, which a second one would be too.
if resp.status_code >= 500 and attempt < UPSTREAM_RETRIES:
log.debug("LLM server replied HTTP %s (%s), asking again", resp.status_code, detail)
continue
log.warning("LLM server responded with HTTP %s %s", resp.status_code, detail)
return None, None return None, None
return None, None # pragma: no cover - the loop always returns return None, None # pragma: no cover - the loop always returns
+25 -1
View File
@@ -473,7 +473,7 @@ class PluginAISummary(SearxTestCase):
self.assertEqual(res.status_code, 502) self.assertEqual(res.status_code, 502)
self.assertEqual(2, len(attempts)) self.assertEqual(2, len(attempts))
def test_endpoint_does_not_retry_an_error_status(self): def test_endpoint_does_not_retry_a_client_error(self):
# a wrong API key or an unknown model does not fix itself # a wrong API key or an unknown model does not fix itself
attempts = [] attempts = []
bad = sse_stream_mock([], status_code=401) bad = sse_stream_mock([], status_code=401)
@@ -492,6 +492,30 @@ class PluginAISummary(SearxTestCase):
self.assertEqual(res.status_code, 502) self.assertEqual(res.status_code, 502)
self.assertEqual(1, len(attempts)) self.assertEqual(1, len(attempts))
def test_endpoint_retries_a_server_error(self):
# 5xx means "not right now" -- e.g. a model that is still loading;
# this is the failure that arrives immediately instead of timing out
attempts = []
ok = sse_stream_mock([{"choices": [{"delta": {"content": "hi"}}]}])
bad = sse_stream_mock([], status_code=503)
@contextmanager
def stream(*_args, **_kwargs):
attempts.append(1)
src = bad if len(attempts) == 1 else ok
with src.stream() as resp:
yield resp
client_mock = Mock()
client_mock.stream = stream
self.mock_upstream(client_mock)
res = self.client.post("/ai_summary", json={"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(res.status_code, 200)
self.assertEqual(2, len(attempts))
lines = [json.loads(line) for line in res.data.decode().splitlines() if line]
self.assertEqual(lines[0], {"delta": "hi"})
def test_endpoint_upstream_error(self): def test_endpoint_upstream_error(self):
self.mock_upstream(sse_stream_mock([], status_code=500)) self.mock_upstream(sse_stream_mock([], status_code=500))