Skip to content

fix(discord): retry 429s in the standalone send path, honoring retry_after - #44488

Open
AIalliAI wants to merge 1 commit into
NousResearch:mainfrom
AIalliAI:fix/44468-discord-standalone-429-retry
Open

fix(discord): retry 429s in the standalone send path, honoring retry_after#44488
AIalliAI wants to merge 1 commit into
NousResearch:mainfrom
AIalliAI:fix/44468-discord-standalone-429-retry

Conversation

@AIalliAI

@AIalliAI AIalliAI commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #44468hermes send --to discord:... dropped all remaining chunks of a multi-chunk message when one chunk hit a 429, because the standalone REST path (plugins/platforms/discord/adapter.py::_standalone_send) converted any 429 straight into an error dict with no retry, and the Discord chunk loop in tools/send_message_tool.py returns on the first error.

This adds _standalone_post_with_retry to the Discord plugin and applies it to the standalone text-message POST and both forum thread-creation POSTs:

  • Bounded retry: 3 attempts (same as the Telegram helper _send_telegram_message_with_retry).
  • Honors retry_after: parsed from the 429 JSON body first (Discord reports sub-second precision there, e.g. the retry_after: 0.3 from the issue), falling back to the Retry-After / X-RateLimit-Reset-After headers, then to exponential backoff (1s, 2s) when neither parses.
  • Capped: a retry_after above 10s (long/global rate limit) fails fast instead of stalling the blocking CLI/tool call.
  • No behavior change otherwise: non-429 errors keep single-attempt behavior, and error dict formats are byte-identical, so the chunk loop and other consumers see the same shapes.
  • The forum multipart body is rebuilt per attempt via a form factory — an aiohttp FormData cannot be sent twice.

With retry inside _standalone_send, the chunk loop in send_message_tool.py needs no change: a transient 429 on chunk N now retries and succeeds, and the remaining chunks proceed — parity with the Telegram path, which retries inside _send_telegram. (The per-media upload loop is left as-is: its failures are already non-fatal warnings that don't drop subsequent sends, and wrapping it would change its streaming-from-disk memory profile.)

Tests

New TestSendDiscord429Retry in tests/tools/test_send_message_tool.py:

  • 429 then success → retried after exactly retry_after (0.3s), send succeeds, 2 POSTs
  • persistent 429 → gives up after 3 attempts, surfaces the 429 error dict
  • retry_after: 3600 → fails fast, no sleep, 1 POST
  • 403 → not retried (old behavior preserved)
  • unparseable 429 body → exponential backoff fallback (1s)

tests/tools/test_send_message_tool.py: 138 passed (35 Discord-related, 5 new).

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter P2 Medium — degraded but workaround exists labels Jun 11, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

Verified the Discord standalone 429 retry implementation — clean, well-structured.

What I checked:

  • Bounded retry: _STANDALONE_RETRY_ATTEMPTS = 3 with proper attempt counting ✅
  • retry_after extraction: JSON body first (sub-second precision), then Retry-After/X-RateLimit-Reset-After headers ✅
  • Max retry_after cap (_STANDALONE_MAX_RETRY_AFTER = 10.0): long-lived/global rate limits give up immediately instead of stalling a blocking CLI call ✅
  • Exponential backoff fallback (2 ** attempt) when no parseable retry_after — correct for opaque 429s ✅
  • form_factory pattern: aiohttp FormData cannot be sent twice, so the factory rebuilds per attempt — correct ✅
  • Non-429 errors pass through without retry (original single-attempt behavior preserved) ✅
  • Forum thread creation (both media and no-media paths) + text message send all route through _standalone_post_with_retry
  • Media file payloads extracted to file_payloads list before the factory closure — avoids re-reading files on retry ✅

Test coverage (5 tests): 429→success, persistent 429 exhaustion, huge retry_after gives up, non-429 not retried, no retry_after → backoff. Covers the key paths.

Parity with the Telegram retry helper in tools/send_message_tool is noted in the comment — the approach is consistent.

@AIalliAI

Copy link
Copy Markdown
Contributor Author

Requesting maintainer review — this is ready to land from my side. Standalone fork CI is pending first-run approval here; the rollup branch in #44061 carrying this session's batch is fully green on upstream CI (all test shards, typecheck, e2e).

…after

The standalone REST send path (used by send_message_tool when no live gateway runner is in-process) treated a 429 like any other non-2xx: it read the body and returned an error immediately, so a transient rate limit dropped the message instead of backing off. Discord reports a precise retry_after on these responses, which the path ignored.

- add a bounded _standalone_post_with_retry wrapper (3 attempts) that honors retry_after from the JSON body first, then the Retry-After / X-RateLimit-Reset-After headers, falling back to exponential backoff

- cap the wait at 10s so a long-lived/global limit gives up instead of stalling a blocking CLI/tool call

- route the text, forum-thread, and forum-multipart sends through the wrapper, rebuilding the FormData per attempt since aiohttp bodies can't be re-sent

- read every response through the existing bounded json/text limiters so the retry path keeps main's oversized-body protection

Fixes NousResearch#44468
@AIalliAI
AIalliAI force-pushed the fix/44468-discord-standalone-429-retry branch from 7010406 to 80c971f Compare July 11, 2026 03:14
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused Discord delivery fix. The premise is present on current main: the standalone text POST returns immediately for every non-2xx status at plugins/platforms/discord/adapter.py:8053-8063, and the chunk loop stops on that error at tools/send_message_tool.py:893-906. The PR's bounded 429-only retry therefore addresses the failure without changing the chunk-loop contract.

The PR base is an ancestor of current main; the only subsequent change to either touched file is an unrelated slash-command description at plugins/platforms/discord/adapter.py:4085, so this appears mechanically salvageable. No blocking defects found in the reviewed diff.

Automated hermes-sweeper review.

@alt-glitch alt-glitch added comp/plugins Plugin system and bundled plugins sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages and removed comp/gateway Gateway runner, session dispatch, delivery labels Jul 14, 2026
@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 14, 2026
@andrexibiza

Copy link
Copy Markdown
Contributor

Discord Feature Parity & Alignment Campaign interlock: tracked by EPIC #79564. Original contributor lane; preservation and integration routing remain explicit in the campaign ledger.

@alt-glitch alt-glitch added P3 Low — cosmetic, nice to have and removed P2 Medium — degraded but workaround exists labels Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/discord Discord bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: hermes send to Discord drops remaining chunks on a 429 — no retry in the standalone send path (Telegram has one)

5 participants