Conversation
dd09293 to
acda6db
Compare
|
Independently hit this same bug in production today and arrived at the same root-cause analysis, so this is confirmation from a second deployment plus two gaps I think the current diff leaves open. Independent reproductionTelegram gateway, two consecutive turns silently lost. The log shows the exact sequence this PR describes: Both turns completed and burned their tokens; the user received nothing and no notice. From their side the agent looked hung, which is a rough failure mode — they were (understandably) annoyed that it "got stuck" twice. Gap 1 —
|
acda6db to
f58bbdd
Compare
|
Friendly nudge on this one — both of these landed the same day from independent deployments, and the real-world impact is aggravating: a single flood-capped send currently falls through to the plain-text fallback, re-enters the server ban, and silently eats the turn (user sees a reply that never arrives). It recurs repeatedly. marian001 already confirmed the root-cause analysis independently, and CI is green on the rebased head ( @EmpireOperating @Teknium — review when you get a chance; happy to rebase if anything's drifted since. |
|
@marian001 your two gaps are already folded into the current head ( Gap 1 (Weixin / no- Gap 2 (fallback reachable + truncating): added an early return before the plain-text fallback when the failure classifies as Current head includes 2 regression tests (rate-limited-without- Thanks again for the independent confirmation and for widening the gate — much appreciated. |
|
@cdepuy Confirmed — pulled One residual issue on the current head, though: 1. Flood, then a genuine formatting error → fallback becomes unreachableThe flag stays 2. Network error, then a rate limit → retry budget silently abandonedThis is the Weixin case the PR set out to cover, just arriving on a later attempt. The pre-fallback guard does still save it from the truncating fallback, so nothing is clipped — but the send fails when it did not have to. Fix — one line, where
|
Review feedback — change needed before mergeThe rate-limit classification is calculated from the initial send result and then reused throughout the retry loop. That value can become stale when the failure kind changes between attempts. Please recalculate Please add regression coverage for both transitions:
The existing tests cover initial rate limiting and exhausted retries, but not these failure-type transitions. Once the per-attempt classification and those tests are in place, the overall approach looks right. |
|
Addressed the review feedback (per-attempt reclassification + both failure-type transition tests). Pushed as Code change ( Two regression tests (in
Both new tests FAIL against the prior head ( Noting that GitHub's head-ref cache was still resolving the PR to |
There was a problem hiding this comment.
Verified on fd59a4b1. The retry loop's else branch still sends the failure notice during the latest flood penalty.
With three SendResult(success=False, error="flood_control:189.0", retry_after=189.0) results, max_retries=2, a fake clock and zero jitter, send times are [0, 189, 378, 378]. The fourth send is the notice, immediately after another 189-second refusal.
Return the failure before that notice:
if self._is_rate_limited_error(error_str) or result.retry_after is not None:
return resultUpdate test_rate_limited_exhausted_returns_typed_failure_not_fallback to expect three sends; it currently requires the fourth. No extra timer or retry is needed.
Validation through scripts/run_tests.sh: added regression failed, 22 existing tests passed. With this guard, the regression and three network-notice/failure-transition controls passed. Full suite not run.
… of plain-text fallback Flood-capped / rate-limited sends (Telegram FloodWait, Weixin bare RuntimeError) were not honored as retryable when retry_after was absent, so _send_with_retry fell through to the truncating plain-text fallback, which re-entered the server ban and dropped the message tail — silently burning the turn with no delivery. Three changes to _send_with_retry: 1. Route rate-limit checks through _is_rate_limited_error (single wrapper over classify_send_error) so rate limits are treated as transient even when the platform omits retry_after. 2. Reclassify rate-limit per retry attempt, not just the initial send, so the in-loop break/continue reflects the CURRENT attempt (covers transient->flood and rate-limited->formatting transitions). 3. Never take the truncating plain-text fallback for a rate-limited send — return the typed failure so the delivery ledger owns redelivery after the cooldown. And when retries exhaust on a rate-limited / retry_after-carrying failure, return BEFORE sending the delivery-failure notice: the notice send would land inside the same flood penalty and re-enter the ban ([0, 189, 378, 378] -> 3 sends, no 4th). Ordinary exhausted network errors keep the existing notice. Honors server retry_after (when present) as the backoff delay instead of the default exponential schedule. Rebased onto current main (was 5163 commits behind, CONFLICTING) and folded in reviewer feedback. Regression coverage in tests/gateway/test_send_retry.py: rate-limit classifier, rate-limited-without-retry_after retries & succeeds, rate-limited exhaustion returns a typed failure with no fallback and no notice inside the active flood penalty, and failure-kind transitions between attempts.
fd59a4b to
ebfdea0
Compare
|
@gertsio — addressed. Thank you for the precise repro; it was spot on. The exhaustion- if self._is_rate_limited_error(error_str) or result.retry_after is not None:
logger.error(... "returning typed failure for redelivery (no notice sent inside active flood penalty)")
return resultYour exact scenario (three
CI on this fork PR requires maintainer approval to run (
@EmpireOperating @Teknium — friendly ping: CI needs a maintainer to approve the run on this fork PR; the branch is mergeable. Happy to rebase again if anything has drifted since |
…d failure past 60s Review follow-up on the salvaged #100072 flood handling. Honouring retry_after is right, but sleeping it verbatim in _send_with_retry would pin the send coroutine for a long penalty (a 97-minute FloodWait once froze inbound on every platform, #91969 -- the Telegram adapter already fails closed at 5s for the same reason and hands the wait to this loop). Past 60s the typed failure goes back to the delivery ledger, which owns redelivery after the cooldown. The six classifier tests collapse into one parametrized contract.
|
Merged via #104370 as a cherry-pick of your commit (authorship preserved) — main What landed: your classifier-based rate-limit handling, per-attempt reclassification (@EmpireOperating's ask), and the typed failure before any delivery-failure notice (@gertsio's ask), no truncating plain-text fallback for a rate-limited send. Live probe with a stub adapter returning Two follow-ups of mine on top: (1) the inline Thanks, and to @marian001 for the second-deployment repro. #103754 touches the same send path and will need a rebase onto this. Closing in favour of the merged salvage. |
Summary
Fixes a Telegram send-path bug that amplifies flooding and can lock the bot out of sending for an hour+.
The Telegram adapter's fail-closed flood path (
_flood_cap_resultinplugins/platforms/telegram/adapter.py) returns aSendResultcarryingretry_after(the server's FloodWait seconds) but does not setretryable=True._send_with_retryingateway/platforms/base.pygated its retry-with-backoff path purely onresult.retryable or self._is_retryable_error(error), and theflood_control:<n>error string is not in_RETRYABLE_ERROR_PATTERNS. Result: an over-cap FloodWait skips backoff entirely and falls straight into the plain-text fallback send, which immediately re-hits the same ban.Repeated while a long ban is active, this renews the FloodWait and keeps the bot from delivering anything for tens of minutes. Observed in production: ~35x send amplification against a 1h+ ban (130 real responses -> ~4,600 send attempts), across 6 separate days.
Fix
Four coordinated changes to
_send_with_retryingateway/platforms/base.py(+1_is_rate_limited_errorhelper):classify_send_error(...) == "rate_limited"in addition toretryable/retry_after, so flood-capped sends from any platform — including Weixin, which surfaces a bareRuntimeErrorwith noretry_after— route into the retry path instead of the truncating plain-text fallback.retry_afteras the backoff delay (Telegram FloodWait seconds) instead of the default exponential schedule. Retry-aware: a retry that itself returns a newretry_afterre-honors it.[0, 189, 378, 378]→[0, 189, 378]; no 4th send). Ordinary exhausted network errors keep the existing notice behavior.Timeout results are unaffected:
retry_afterisNonefor them, sois_networkstaysFalseand the no-duplicate-on-timeout guarantee is preserved.Files changed
gateway/platforms/base.py:_is_rate_limited_errorhelper + rewritten_send_with_retry.tests/gateway/test_send_retry.py: 10 new regression tests — rate-limit classifier, rate-limited-without-retry_afterretries-and-succeeds, rate-limited exhaustion returns a typed failure (no fallback, no notice inside the penalty), and failure-kind transitions between attempts.Rebased onto current
main(resolves a 5000+ commit drift / CONFLICTING state). Reviewer feedback from a third-party deployment confirmed the root cause independently and identified the retry-loopelse-branch notice issue; that is folded in and covered by an updated regression test.Test plan
Run locally against current
main(Python 3.11 / 3.12). CI on this fork-PR awaits maintainer approval to execute (action_required).Relationship to #103669 (flood-ledger redelivery)
This PR is the inline-retry half of the FloodWait handling: it makes an over-cap send honor the server's
retry_afterand back off inside_send_with_retry. #103669 (fix/ledger-flood-retryby @AlexxRussell) is the deferred half: it assigns long flood waits to the delivery ledger's scheduled redelivery. The two are complementary, not duplicates — #103669 touches_finalize_delivery_obligation/delivery_ledger.py(the post-exhaustion path), while this PR fixes the inline retry loop itself (the pre-exhaustion path). Ownership boundary is explicit: inline backoff here, ledger redelivery there. Happy to rebase or adjust this PR to land cleanly alongside #103669 either order.