fix: honour server-provided Retry-After in bot rate limiting/retries#2431
fix: honour server-provided Retry-After in bot rate limiting/retries#2431praisonai-triage-agent[bot] wants to merge 3 commits into
Conversation
…ixes #2427) Outbound send/retry paths now extract the server-mandated wait (Telegram parameters.retry_after / HTTP Retry-After header, with a text fallback) via a new server_retry_after() helper and honour it over the generic exponential backoff. The RateLimiter gains an adaptive penalise() that widens the affected channel (or global) lane for the window so the next sends do not immediately re-trip the platform's throttle. Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com>
|
@coderabbitai review |
|
/review |
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
✅ Action performedReview finished.
|
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds ChangesServer Retry-After honoring
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings. Review areas:
|
Greptile SummaryThis PR makes bot retry handling use server-provided throttle delays.
Confidence Score: 4/5This is close, but one durable retry path should be fixed before merging.
src/praisonai/praisonai/bots/_delivery.py Important Files Changed
Reviews (2): Last reviewed commit: "chore: drop unused AsyncMock import in s..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
src/praisonai/tests/unit/bots/test_server_retry_after.py (1)
5-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd the missing
_outbox.OutboundQueue.drain()test or drop this claim.This module says it covers
OutboundQueuegating, but it never imports or exercises_outbox. Since_outbox.OutboundQueue.drain()is one of the changed retry paths in this PR, that branch is still unverified here.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/praisonai/tests/unit/bots/test_server_retry_after.py` around lines 5 - 10, The test module currently claims coverage for OutboundQueue gating but does not import or exercise _outbox.OutboundQueue.drain(). Add a focused test that drives the drain() retry path using the stored server hint, or remove that claim from the module docstring if you are not covering it. Use the existing server_retry_after(), deliver_with_retry(), and RateLimiter.penalise() test patterns as a guide and reference _outbox.OutboundQueue.drain() directly so the changed branch is actually verified.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/praisonai/praisonai/bots/_delivery.py`:
- Around line 354-365: Only penalize the limiter when the wait is
server-mandated, not for generic backoff. In `_delivery.py` within the
send/retry flow where `server_retry_after(e)` and `compute_backoff(backoff,
attempt)` are used, update the `rate_limiter.penalise(channel_id, delay)` call
so it runs only when `mandated` is not `None` and `delay` comes from that
mandated value. Keep the backoff delay for sleeping/retry logic, but avoid
widening the channel lane on ordinary transient errors.
In `@src/praisonai/praisonai/bots/_outbox.py`:
- Around line 355-358: Persist the retry-after hint on the outbox entry instead
of reconstructing it from entry.error in the retry path. Update the
failure-marking flow in the outbox logic so the structured retry delay/timestamp
is saved when the error is recorded, then have the retry handling around
server_retry_after in the outbox processor read that persisted value directly
rather than wrapping entry.error in Exception. Use the existing outbox
entry/error handling methods and the server_retry_after call site to locate the
change.
In `@src/praisonai/praisonai/bots/_rate_limit.py`:
- Around line 118-120: The global penalty handling in the rate limiter should
not collapse all waiting callers onto the same wake-up without preserving token
spacing. Update the reservation logic in the rate-limit path around
_global_penalty_until and global_wait so the delayed time is used as the new
token reservation anchor, or fold the penalty directly into the token
reservation calculation, ensuring queued sends still remain staggered after the
penalty expires.
In `@src/praisonai/tests/unit/bots/test_server_retry_after.py`:
- Line 224: The unpacked tuple element in the test run result is unused, which
keeps Ruff RUF059 active. Update the `run()` result handling in
`test_server_retry_after` so the second value is intentionally ignored or
clearly marked unused, while keeping the existing `success` assertion flow
unchanged.
- Around line 198-227: This test only verifies lane penalisation, not that
deliver_with_retry in _delivery honors the server Retry-After value. Update the
test around deliver_with_retry and fake_sleep_with_abort to capture the sleep
duration used after HTTPRateLimit("5"), and assert it waits approximately 5
seconds rather than falling back to generic backoff. Keep the existing
RateLimiter and Adapter setup, but strengthen the assertion on the _delivery
retry path so the behavior in deliver_with_retry is validated directly.
---
Nitpick comments:
In `@src/praisonai/tests/unit/bots/test_server_retry_after.py`:
- Around line 5-10: The test module currently claims coverage for OutboundQueue
gating but does not import or exercise _outbox.OutboundQueue.drain(). Add a
focused test that drives the drain() retry path using the stored server hint, or
remove that claim from the module docstring if you are not covering it. Use the
existing server_retry_after(), deliver_with_retry(), and RateLimiter.penalise()
test patterns as a guide and reference _outbox.OutboundQueue.drain() directly so
the changed branch is actually verified.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 57588c21-e9c6-49d1-98e3-f41e716ab129
📒 Files selected for processing (5)
src/praisonai/praisonai/bots/_delivery.pysrc/praisonai/praisonai/bots/_outbox.pysrc/praisonai/praisonai/bots/_rate_limit.pysrc/praisonai/praisonai/bots/_resilience.pysrc/praisonai/tests/unit/bots/test_server_retry_after.py
| # Honour a server-mandated wait (Telegram retry_after, HTTP | ||
| # Retry-After) over the generic backoff estimate. | ||
| mandated = server_retry_after(e) | ||
| delay = mandated if mandated is not None else compute_backoff(backoff, attempt) | ||
|
|
||
| # Widen this channel's lane so the next send does not immediately | ||
| # re-trip the platform limit. | ||
| if rate_limiter is not None and delay > 0: | ||
| try: | ||
| await rate_limiter.penalise(channel_id, delay) | ||
| except Exception: | ||
| pass |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Only penalize the limiter for server-mandated waits.
When mandated is None, delay is just generic backoff, but Line 363 still widens the channel lane. That can unnecessarily throttle future sends after ordinary transient errors.
Proposed fix
- if rate_limiter is not None and delay > 0:
+ if mandated is not None and rate_limiter is not None and delay > 0:
try:
await rate_limiter.penalise(channel_id, delay)
except Exception:
pass📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # Honour a server-mandated wait (Telegram retry_after, HTTP | |
| # Retry-After) over the generic backoff estimate. | |
| mandated = server_retry_after(e) | |
| delay = mandated if mandated is not None else compute_backoff(backoff, attempt) | |
| # Widen this channel's lane so the next send does not immediately | |
| # re-trip the platform limit. | |
| if rate_limiter is not None and delay > 0: | |
| try: | |
| await rate_limiter.penalise(channel_id, delay) | |
| except Exception: | |
| pass | |
| # Honour a server-mandated wait (Telegram retry_after, HTTP | |
| # Retry-After) over the generic backoff estimate. | |
| mandated = server_retry_after(e) | |
| delay = mandated if mandated is not None else compute_backoff(backoff, attempt) | |
| # Widen this channel's lane so the next send does not immediately | |
| # re-trip the platform limit. | |
| if mandated is not None and rate_limiter is not None and delay > 0: | |
| try: | |
| await rate_limiter.penalise(channel_id, delay) | |
| except Exception: | |
| pass |
🧰 Tools
🪛 Ruff (0.15.18)
[error] 364-365: try-except-pass detected, consider logging the exception
(S110)
[warning] 364-364: Do not catch blind exception: Exception
(BLE001)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai/praisonai/bots/_delivery.py` around lines 354 - 365, Only
penalize the limiter when the wait is server-mandated, not for generic backoff.
In `_delivery.py` within the send/retry flow where `server_retry_after(e)` and
`compute_backoff(backoff, attempt)` are used, update the
`rate_limiter.penalise(channel_id, delay)` call so it runs only when `mandated`
is not `None` and `delay` comes from that mandated value. Keep the backoff delay
for sleeping/retry logic, but avoid widening the channel lane on ordinary
transient errors.
| if entry.error: | ||
| mandated = server_retry_after(Exception(entry.error)) | ||
| if mandated is not None: | ||
| delay = max(delay, mandated) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Persist the structured retry hint instead of reparsing str(error).
Exception(entry.error) loses Telegram attributes and HTTP headers, so durable retries only honor hints that happen to survive in text. Store a retry-after timestamp/delay when marking the failure, then use that persisted value here.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai/praisonai/bots/_outbox.py` around lines 355 - 358, Persist the
retry-after hint on the outbox entry instead of reconstructing it from
entry.error in the retry path. Update the failure-marking flow in the outbox
logic so the structured retry delay/timestamp is saved when the error is
recorded, then have the retry handling around server_retry_after in the outbox
processor read that persisted value directly rather than wrapping entry.error in
Exception. Use the existing outbox entry/error handling methods and the
server_retry_after call site to locate the change.
| # Honour any active global penalty window (e.g. a platform-wide 429). | ||
| if self._global_penalty_until > now + global_wait: | ||
| global_wait = self._global_penalty_until - now |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Preserve token spacing after a global penalty.
Line 120 collapses all callers to the same _global_penalty_until wake-up without reserving token slots at that delayed time, so a burst of queued sends can fire together when the global hold-off expires. Update the token reservation anchor after applying the penalty, or fold the penalty into the reservation calculation.
Possible direction
# Honour any active global penalty window (e.g. a platform-wide 429).
if self._global_penalty_until > now + global_wait:
global_wait = self._global_penalty_until - now
+ self._last_refill = max(self._last_refill, now + global_wait)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # Honour any active global penalty window (e.g. a platform-wide 429). | |
| if self._global_penalty_until > now + global_wait: | |
| global_wait = self._global_penalty_until - now | |
| # Honour any active global penalty window (e.g. a platform-wide 429). | |
| if self._global_penalty_until > now + global_wait: | |
| global_wait = self._global_penalty_until - now | |
| self._last_refill = max(self._last_refill, now + global_wait) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai/praisonai/bots/_rate_limit.py` around lines 118 - 120, The
global penalty handling in the rate limiter should not collapse all waiting
callers onto the same wake-up without preserving token spacing. Update the
reservation logic in the rate-limit path around _global_penalty_until and
global_wait so the delayed time is used as the new token reservation anchor, or
fold the penalty directly into the token reservation calculation, ensuring
queued sends still remain staggered after the penalty expires.
| async def fake_sleep_with_abort(seconds, abort_signal=None): | ||
| return True | ||
|
|
||
| monkeypatch.setattr(_delivery, "sleep_with_abort", fake_sleep_with_abort) | ||
|
|
||
| limiter = RateLimiter(RateLimitConfig(messages_per_second=1000, per_channel_delay=0.0, burst_size=100)) | ||
|
|
||
| calls = {"n": 0} | ||
|
|
||
| class Adapter: | ||
| async def send_message(self, channel_id, content, **kwargs): | ||
| calls["n"] += 1 | ||
| if calls["n"] == 1: | ||
| raise HTTPRateLimit("5") | ||
| return "ok" | ||
|
|
||
| async def run(): | ||
| return await _delivery.deliver_with_retry( | ||
| Adapter(), | ||
| "chanX", | ||
| "hi", | ||
| max_attempts=3, | ||
| platform="discord", | ||
| rate_limiter=limiter, | ||
| ) | ||
|
|
||
| success, err = asyncio.run(run()) | ||
| assert success is True | ||
| # The lane should have been penalised by ~5s. | ||
| assert limiter._channel_penalty_until.get("chanX", 0) > 0 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Assert the _delivery wait duration, not just lane penalisation.
Right now this test only proves that rate_limiter.penalise() was called. It would still pass if _delivery.deliver_with_retry() ignored the server's Retry-After: 5 and slept its generic backoff instead, which is one of the main behaviors this PR is changing.
Proposed assertion
+ slept = []
+
async def fake_sleep_with_abort(seconds, abort_signal=None):
+ slept.append(seconds)
return True
@@
- success, err = asyncio.run(run())
+ success, err = asyncio.run(run())
assert success is True
+ assert slept == [pytest.approx(5.0, abs=0.01)]
# The lane should have been penalised by ~5s.
assert limiter._channel_penalty_until.get("chanX", 0) > 0📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async def fake_sleep_with_abort(seconds, abort_signal=None): | |
| return True | |
| monkeypatch.setattr(_delivery, "sleep_with_abort", fake_sleep_with_abort) | |
| limiter = RateLimiter(RateLimitConfig(messages_per_second=1000, per_channel_delay=0.0, burst_size=100)) | |
| calls = {"n": 0} | |
| class Adapter: | |
| async def send_message(self, channel_id, content, **kwargs): | |
| calls["n"] += 1 | |
| if calls["n"] == 1: | |
| raise HTTPRateLimit("5") | |
| return "ok" | |
| async def run(): | |
| return await _delivery.deliver_with_retry( | |
| Adapter(), | |
| "chanX", | |
| "hi", | |
| max_attempts=3, | |
| platform="discord", | |
| rate_limiter=limiter, | |
| ) | |
| success, err = asyncio.run(run()) | |
| assert success is True | |
| # The lane should have been penalised by ~5s. | |
| assert limiter._channel_penalty_until.get("chanX", 0) > 0 | |
| slept = [] | |
| async def fake_sleep_with_abort(seconds, abort_signal=None): | |
| slept.append(seconds) | |
| return True | |
| monkeypatch.setattr(_delivery, "sleep_with_abort", fake_sleep_with_abort) | |
| limiter = RateLimiter(RateLimitConfig(messages_per_second=1000, per_channel_delay=0.0, burst_size=100)) | |
| calls = {"n": 0} | |
| class Adapter: | |
| async def send_message(self, channel_id, content, **kwargs): | |
| calls["n"] += 1 | |
| if calls["n"] == 1: | |
| raise HTTPRateLimit("5") | |
| return "ok" | |
| async def run(): | |
| return await _delivery.deliver_with_retry( | |
| Adapter(), | |
| "chanX", | |
| "hi", | |
| max_attempts=3, | |
| platform="discord", | |
| rate_limiter=limiter, | |
| ) | |
| success, err = asyncio.run(run()) | |
| assert success is True | |
| assert slept == [pytest.approx(5.0, abs=0.01)] | |
| # The lane should have been penalised by ~5s. | |
| assert limiter._channel_penalty_until.get("chanX", 0) > 0 |
🧰 Tools
🪛 Ruff (0.15.18)
[warning] 224-224: Unpacked variable err is never used
Prefix it with an underscore or any other dummy variable pattern
(RUF059)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai/tests/unit/bots/test_server_retry_after.py` around lines 198 -
227, This test only verifies lane penalisation, not that deliver_with_retry in
_delivery honors the server Retry-After value. Update the test around
deliver_with_retry and fake_sleep_with_abort to capture the sleep duration used
after HTTPRateLimit("5"), and assert it waits approximately 5 seconds rather
than falling back to generic backoff. Keep the existing RateLimiter and Adapter
setup, but strengthen the assertion on the _delivery retry path so the behavior
in deliver_with_retry is validated directly.
| rate_limiter=limiter, | ||
| ) | ||
|
|
||
| success, err = asyncio.run(run()) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Rename the unused tuple element.
err is never used after unpacking, so this keeps Ruff's RUF059 warning alive.
Proposed fix
- success, err = asyncio.run(run())
+ success, _err = asyncio.run(run())📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| success, err = asyncio.run(run()) | |
| success, _err = asyncio.run(run()) |
🧰 Tools
🪛 Ruff (0.15.18)
[warning] 224-224: Unpacked variable err is never used
Prefix it with an underscore or any other dummy variable pattern
(RUF059)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/praisonai/tests/unit/bots/test_server_retry_after.py` at line 224, The
unpacked tuple element in the test run result is unused, which keeps Ruff RUF059
active. Update the `run()` result handling in `test_server_retry_after` so the
second value is intentionally ignored or clearly marked unused, while keeping
the existing `success` assertion flow unchanged.
Source: Linters/SAST tools
|
@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding. Phase 1: Review per AGENTS.md
Phase 2: FIX Valid Issues Phase 3: Final Verdict |
|
Claude finished @MervinPraison's task —— View job Final Architecture Review — PR #2431 ✅Pushed fixes to Phase 1 — Architecture (per AGENTS.md)
Phase 2 — Fixes applied (reviewer findings verified valid)
Skipped: none of the flagged items — all were valid and addressed. (Pre-existing E501 line-length warnings in the test file are not introduced by this PR and left untouched per minimal-change scope.) Phase 3 — VerdictApprove. All reviewer-flagged issues resolved. Tests: 41 passed (
|
- _delivery: only penalise the rate limiter on a server-mandated wait, not on generic backoff after ordinary transient errors (Greptile P1 + CodeRabbit) - _rate_limit: anchor refill clock to a global penalty's end so queued sends stay staggered instead of firing together when the hold-off expires (CodeRabbit) - _outbox: persist the structured server Retry-After hint from the live exception into the stored error text so the durable retry gate honours it (Greptile P1 + CodeRabbit); surface transient drain failures via a recoverable _TransientDeliveryError so the hint is not lost - tests: assert deliver_with_retry sleeps the mandated 5s, fix RUF059 unused var, add OutboundQueue.drain gating coverage Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
|
@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding. Phase 1: Review per AGENTS.md
Phase 2: FIX Valid Issues Phase 3: Final Verdict |
|
Claude finished @MervinPraison's task —— View job Final Architecture Review — PR #2431 ✅
Pushed Phase 1 — Architecture (per AGENTS.md)
Phase 2 — Reviewer findings verified against current code
Skipped (with reason):
Phase 3 — VerdictApprove (architecturally). All reviewer-flagged issues are resolved and verified in the current tree.
|
Resolves the Ruff F401 introduced by the new test module. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
|
@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding. Phase 1: Review per AGENTS.md
Phase 2: FIX Valid Issues Phase 3: Final Verdict |
|
Claude finished @MervinPraison's task —— View job Final Architecture Review — PR #2431 ✅
Branch tip Phase 1 — Architecture (per AGENTS.md)
Phase 2 — Reviewer findings verified against current code
Skipped (with reason):
Phase 3 — VerdictApprove (architecturally). Ready to merge. All reviewer-flagged issues are resolved and verified in the current tree; no further code changes needed.
|
| # Retry-After hint carried in that text. Returning False would store | ||
| # a generic "Delivery returned false" and lose the hint. | ||
| if not success and error: | ||
| raise _TransientDeliveryError(error) |
There was a problem hiding this comment.
Retry hint lost When
drain_pending() retries an outbox entry, it calls deliver_with_retry() with one attempt. A server throttle exception is caught there and converted into a plain error string, and this line wraps only that string in _TransientDeliveryError. The outbox then sees the wrapper instead of the original Telegram or HTTP exception, so fields like .retry_after, .parameters, and response.headers are gone. Common messages such as Retry in 30 seconds or 429 Too Many Requests do not match the text fallback, so the stored error lacks a recoverable retry_after hint and the next drain can retry after the local backoff instead of the server-mandated wait.
Fixes #2427
Summary
Bot outbound send/retry paths now honour an explicit server throttle signal instead of falling back to a generic exponential backoff.
server_retry_after(err)helper inbots/_resilience.pyextracts the mandated wait:parameters.retry_after(and the.retry_afterattr exposed by python-telegram-bot'sRetryAfter).Retry-Afterheader as integer seconds or an HTTP-date.retry after <n>/retry_after: <n>in the message._resilience.deliver_with_retry_resilience.ConnectionMonitor.record_error_delivery.deliver_with_retry_outbox.OutboundQueue.drain(gates the next retry by the stored hint)RateLimitergains adaptivepenalise(channel_id, seconds)lane-widening (per-channel + global penalty windows), wired into_delivery.deliver_with_retryvia a new optionalrate_limiter=arg so the next sends do not immediately re-trip the limit.Layer placement
Wrapper-only (
src/praisonai/praisonai/bots/) transport resilience. No core/Agent changes, no new dependencies, no new user-facing surface, fully backward-compatible.Test plan
tests/unit/bots/test_server_retry_after.py(12 tests): hint extraction (Telegram attr/params, HTTP seconds/HTTP-date, text fallback, none),ConnectionMonitorhonour-vs-backoff,RateLimiter.penaliselane widening + zero no-op, anddeliver_with_retryhonouring 30s flood_wait + penalising the lane. All pass.test_outbound_retry.py,test_delivery_imports.py,test_dlq.py,test_outbox_reconcile.py(28 tests) still pass.Generated with Claude Code
Summary by CodeRabbit
New Features
Retry-Afterand similar throttle signals.Bug Fixes