Skip to content

[OMEGA-423] Fix nginx proxy cutting off slow LLM responses (#321) - #351

Open
Leul-Negash wants to merge 9 commits into
singnet:mainfrom
Leul-Negash:fix/llm-proxy-timeout
Open

Leul-Negash wants to merge 9 commits into
singnet:mainfrom
Leul-Negash:fix/llm-proxy-timeout

Conversation

@Leul-Negash

@Leul-Negash Leul-Negash commented Sep 15, 2026 •

Copy link
Copy Markdown

Description

Fixes #321.

In the Docker image every LLM request goes through the nginx proxy. The LLM routes use the global 120s proxy_read_timeout/proxy_send_timeout, so a response that takes longer comes back as 504. The OpenAI client retries twice, gets 504 again, and AIProvider.chat returns "", so the agent never replies. That matches the two 504s two minutes apart in the #321 logs.

This sets 600s on the six LLM routes (anthropic, asicloud, openai, asione, openaiapi, openrouter), which is how long the OpenAI client waits and the same override the OpenClaw route got in 27979ed. Channel routes keep the 120s default.

It also adds Autotests/unit/test_nginx_llm_timeouts.py, which fails if an LLM route waits less than 600s, and registers it in run_mandatory.

How Has This Been Tested?

Ran the image with the OpenAIAPI provider pointed at a local OpenAI-compatible endpoint that answers after 150s, on the test channel:

  • main: nginx logged upstream timed out and returned 504 at 120s, three times, then Exception while communicating with LLM: 504 Gateway Time-out and nothing was sent.
  • This branch: the proxy returned 200 after 150s with no retries, and the agent's reply reached the channel.

The new test fails on main (12 failed) and passes here (12 passed). Autotests/unit 73 passed, tests/ 65 passed, and nginx -t passes on the rendered template inside the image.

I couldn't test against ASICloud directly. @olegryabchikov-dot, could you retest the Dune prompt on this branch when you have a chance?

Checklist

  • PR contains autogenerated code
  • Self-review completed
  • Test scenarios above are passed with the version of the code from PR

Every LLM request in the Docker image goes through the nginx proxy, and the
LLM routes used the global 120s proxy_read_timeout and proxy_send_timeout.
When a response takes longer, nginx returns 504, the OpenAI client retries
twice with the same result, and AIProvider.chat ends up returning "", so the
agent never answers.

The OpenAI client waits up to 600s, so set 600s on the anthropic, asicloud,
openai, asione, openaiapi and openrouter routes, the same override the
OpenClaw route already has. The channel routes keep the 120s default.

Add a unit test that reads the template and fails when an LLM route waits
less than 600s, and register it in run_mandatory.

@timur-ashkenov timur-ashkenov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Leul-Negash Could we handle the final provider timeout explicitly?

After the configured timeout and exhausted retries, the agent should stop waiting and send the user a clear status message, such as: “LLM request timed out. Please try again later.” Returning an empty LLM result leaves the task silently abandoned.

@paul-v-snet paul-v-snet changed the title Fix nginx proxy cutting off slow LLM responses (#321) [OMEGA-423] Fix nginx proxy cutting off slow LLM responses (#321) Sep 23, 2026
After a provider request runs out of time and the client's retries are gone,
chat() returned an empty string. The loop then had no command to run, so the
turn ended without a word and the task looked abandoned, which is the symptom
reported in #321.

A timeout now comes back as a (send ...) command carrying a short status
message, the same shape the token-limit case already uses, so the loop delivers
it on the channel the user is on. It covers the client's own timeout and the
408/504/524 statuses a gateway returns when the upstream did not answer in
time; every other failure still returns an empty string, as before.

Add unit tests for which failures count as a timeout, what chat() returns in
each case, and that the message parses into a single send command.
The unit tier runs on the CI runner with plain pytest, where openai and httpx
are not installed, so importing them collected as an error and the job failed.
Stub openai and the configuration module before loading lib_llm_ext, the same
way test_openclaw_unit.py does, and drop the httpx import.
@Leul-Negash

Copy link
Copy Markdown
Author

@timur-ashkenov Done in 4f9f90e.

A timed-out request now returns a (send ...) command with a status message instead of an empty string, so the loop delivers it and the turn no longer ends in silence. Same shape #337 uses for a token-limit reply. It covers the client's own timeout and the 408/504/524 gateway statuses; other failures still return an empty string.

Verified in the image with the provider pointed at an endpoint answering 504 immediately: after the retries the agent runs ((send "LLM request timed out. ...")) and the message arrives on the channel. Over 40 failed requests it was produced 27 times and delivered once, since send in channels.metta drops a message identical to the last one.

Added Autotests/unit/test_llm_timeout_message.py (13 tests) and merged main in to clear the conflict. CI is green.

One check: by "stop waiting" did you mean the total wait as well? Right now the route timeout plus two client retries can take up to ~30 minutes before the message goes out. I can shorten the client timeout or reduce the retries if you want that capped.

@timur-ashkenov

Copy link
Copy Markdown
Collaborator

@timur-ashkenov Done in 4f9f90e.

A timed-out request now returns a (send ...) command with a status message instead of an empty string, so the loop delivers it and the turn no longer ends in silence. Same shape #337 uses for a token-limit reply. It covers the client's own timeout and the 408/504/524 gateway statuses; other failures still return an empty string.

Verified in the image with the provider pointed at an endpoint answering 504 immediately: after the retries the agent runs ((send "LLM request timed out. ...")) and the message arrives on the channel. Over 40 failed requests it was produced 27 times and delivered once, since send in channels.metta drops a message identical to the last one.

Added Autotests/unit/test_llm_timeout_message.py (13 tests) and merged main in to clear the conflict. CI is green.

One check: by "stop waiting" did you mean the total wait as well? Right now the route timeout plus two client retries can take up to ~30 minutes before the message goes out. I can shorten the client timeout or reduce the retries if you want that capped.

@Leul-Negash , Yes, I meant the total wait should be bounded as well. Waiting up to ~30 minutes before notifying the user would still reproduce the poor user experience from the issue.

I suggest keeping the 600-second timeout for one legitimately slow response, but avoiding additional retries after a timeout/504. The agent should send the timeout status once that first request reaches its limit.

The client retried a timed-out request twice before raising, so with the 600s
route timeout a user could wait about half an hour before the status message
went out. Build the chat clients with max_retries=0, so one request runs to its
timeout and the message goes out then. The loop asks again on its next
iteration anyway. Embeddings are untouched, they build their own clients in
src/rag.py.

Add tests that both the proxy and the direct client are built with a single
attempt.
@Leul-Negash

Copy link
Copy Markdown
Author

@timur-ashkenov Understood, bounded in ed1ab86.

The chat clients are now built with max_retries=0, so one request runs to the 600s timeout and the status message goes out as soon as it gives up, instead of after three attempts. The loop asks again on its next iteration anyway. Embeddings are untouched, they build their own clients in src/rag.py.

Verified against an endpoint answering 504 immediately: no retries logged, one request per cycle, and the message reaches the channel a second after the request fails. Two more tests check both the proxy and the direct client are built with a single attempt.

@timur-ashkenov

Copy link
Copy Markdown
Collaborator

@timur-ashkenov Understood, bounded in ed1ab86.

The chat clients are now built with max_retries=0, so one request runs to the 600s timeout and the status message goes out as soon as it gives up, instead of after three attempts. The loop asks again on its next iteration anyway. Embeddings are untouched, they build their own clients in src/rag.py.

Verified against an endpoint answering 504 immediately: no retries logged, one request per cycle, and the message reaches the channel a second after the request fails. Two more tests check both the proxy and the direct client are built with a single attempt.

@Leul-Negash Could we confirm that disabling retries globally is intentional?

max_retries=0 applies to every OpenAI-compatible provider request, not only timeout/504 cases. It also disables the SDK's default recovery for transient connection errors, 408, 409, 429, and 5xx responses.

I agree that the total wait must be bounded, but this changes retry behavior across all providers. Could we either document and explicitly accept this trade-off, or use a retry policy that preserves limited retries for non-timeout transient failures?

Turning the SDK retries off bounded the wait, but it also removed their recovery
for transient failures on every provider. The SDK cannot express the split: it
decides status retries in _should_retry, yet repeats a timed-out request
unconditionally.

So the decision moves into _retrying(). Transient failures (409, 429, 500, 502,
503 and connection errors) get up to three attempts with a short backoff. A
timeout, or a gateway timeout status, is never retried: it already spent the
request timeout and the user is waiting. Nothing is retried once a 60-second
budget is gone, so a failure that already cost minutes does not restart the
wait. All three chat implementations go through it. Embeddings keep the client
defaults, they build their own clients in src/rag.py.

Add tests for the classification, the attempt limit, the budget, and that a
timeout is attempted once.
@Leul-Negash

Copy link
Copy Markdown
Author

@timur-ashkenov Fair point, that was too broad. Fixed in 7bd71f8.

Retries are back for transient failures and skipped only for timeouts. The SDK cannot split it that way: status retries go through _should_retry, but a timed-out request is repeated regardless. So the decision now sits in one helper, _retrying(). 409, 429, 500, 502, 503 and connection errors get up to three attempts with a short backoff. A timeout or 504 is not retried, since it already spent the request timeout. Nothing is retried once a 60s budget is gone, so a slow failure cannot restart the wait. Embeddings keep the SDK defaults, they build their own clients.

Checked in the image: the endpoint answering 504 gives one attempt per cycle and the status message on the channel, the one answering 503 gives three attempts with the backoff and no status message. Tests cover the classification, the attempt limit, the budget, and that a timeout is attempted once. CI is green.

@TossSky

TossSky commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Tested: images built from this PR at 7bd71f8 and from main 6657798, each started by scripts/omega with -p OpenAIAPI -t test. The /openaiapi/ route led to a local OpenAI-compatible stub that delays or fails each request on demand, and the replies were read on the test channel.

What I checked

  • A reply delayed by 150 s arrives after 151 s, in a single request. On main, nginx cuts each of three attempts at 120 s with a 504, and the user gets nothing.
  • A reply delayed by 700 s: after 600 s, the user gets "LLM request timed out. Please try again later.", in a single request.
  • Two 503s in a row, or two dropped connections, are retried, and the reply arrives within 2.5 s, on main and with the PR.
  • All 42 tests in test_nginx_llm_timeouts.py and test_llm_timeout_message.py pass in CI, and Phase 1 goes from 151 tests on main to 193.

529 and Retry-After now end in silence

A 529 followed by a normal answer: main retries and replies in 1.5 s. With the PR, the stub gets one request, the agent logs Error code: 529, and nothing reaches the user.

A 429 with Retry-After: 5 followed by a normal answer: main waits 5 s and replies at 6 s. With the PR, the agent makes three attempts within 1.5 s, all of them get 429, and nothing reaches the user.

Both come from TRANSIENT_STATUSES, which stops at 503, and from the fixed 0.5 s and 1 s sleeps, which do not read Retry-After. The comment above the list says it holds the statuses the SDK retries by default, but on main the SDK also retries 529 and 522.

A second timeout in a row is dropped

The timeout message is the same text every time, and send drops a message equal to the last one it sent. Right after the 700 s case, the next message hit a 504. At 05:14:17, the agent logged RESPONSE: ((send "LLM request timed out. …")) again, and nothing reached the channel in 95 s. A 504 that came after a normal reply, instead of right after another timeout, was delivered in 1 s.

A smaller issue: the message says "the retries were exhausted", while a timeout gets one request.

Verdict: FAIL
@Leul-Negash @timur-ashkenov

Retry classification followed a hand-written status list that stopped at 503, so
529 and 522 were reported as failures instead of retried. It now follows the
same rule as the SDK, 409, 429 and any 5xx, with the timeout statuses left to
_is_timeout_error so they are reported rather than retried.

The wait between attempts ignored Retry-After, so a 429 asking for five seconds
got three attempts inside 1.5s and then silence. _retry_delay uses Retry-After
when the provider sends one in seconds, falls back to the backoff for the
HTTP-date form, and refuses a retry whose wait would not fit in the budget.

The timeout notice was the same text every time, and send drops a message equal
to the last one it sent, so a second timeout in a row left that turn silent.
The notice now carries the time.

Classifying an error no longer reads the exception classes off the client module
directly. With a client that does not define them the lookup raised inside the
except block and masked the error being classified.

Also drop the claim that the retries were exhausted, since a timeout is
attempted once.

Add tests for the 5xx rule, Retry-After in seconds and as a date, a wait that
exceeds the budget, and two notices in a row differing.
@Leul-Negash

Copy link
Copy Markdown
Author

@TossSky Thanks, all four are fixed in 1004c10.

529 and 522: the status list was mine and it stopped at 503. Classification now follows the same rule as the SDK, 409, 429 and any 5xx, with 408, 504 and 524 left to the timeout path so they are reported instead of retried.

Retry-After: the sleeps were fixed at 0.5s and 1s. _retry_delay now uses Retry-After when the provider sends seconds, falls back to the backoff for the HTTP-date form, and skips the retry when that wait would not fit in the 60s budget.

Second timeout in a row: right, the text was identical and send drops a repeat. The notice now carries the time, so consecutive ones differ. I also dropped the "retries were exhausted" wording, since a timeout gets one attempt.

test_llm_budget caught something too: I was reading the exception classes off the openai module inside the except block, so with a stub that does not define them the lookup raised and masked the real error. That lookup is safe now.

Rechecked in the image with a stub that scripts the answers: 529 then a normal answer is retried after 0.5s and the reply arrives; 429 with Retry-After: 5 is retried at 5.0s and the reply arrives; repeated 504 gives one request per cycle, no retries, and six notices in a row all reached the channel with different times. Tests: 38 in test_llm_timeout_message.py, 126 in the unit tier. CI is green.

One thing worth flagging: with a gateway that answers 504 instantly the user gets a notice every cycle. In production a 504 means the proxy already waited 600s, so they are spaced out, and I left it uncapped rather than adding suppression back. Happy to add a minimum interval if you would rather have one.

Comment thread providers/lib_llm_ext.py Outdated
sent, so without it a second timeout in a row would leave that turn silent,
which is the symptom this whole change is about.
"""
message = LLM_TIMEOUT_MESSAGE.format(time=time.strftime("%H:%M:%S"))

@timur-ashkenov timur-ashkenov Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Leul-Negash The timestamp is only precise to one second, so two immediate timeout/504 cycles within the same second still produce identical (send ...) commands. Since send drops a message equal to the previous one, the second user message can again be silently lost. Please use a per-notification unique value (for example, a monotonic nonce or sub-second timestamp).

@Leul-Negash Leul-Negash Sep 28, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timur-ashkenov Good catch, fixed in 76ef473.

The notice now carries the time to the millisecond, and the admin line carries a count that rises with every notice, so two of them differ even when the clock does not move between them. A test freezes the clock and checks that two notices in a row are still different; while checking by hand I hit the case for real, two notices inside the same millisecond, and they differ by the count.

Unit tests are at 39 in test_llm_timeout_message.py and 127 in the unit tier. CI is green.

The notice carried the time to the second, so two cycles failing inside the same
second rendered the same message, and send drops a message equal to the last one
it sent: that turn went silent again.

The notice now carries the time to the millisecond and a count that rises with
every notice, so two of them differ even when the clock does not move. The test
freezes the clock and checks exactly that.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OMEGA-423] Bug 22: Bot does not respond to user messages after processing a complex multi-step task.

3 participants