Give the webhook pass a time budget, and test the receiver limits #1050

Merged
tiagoagueda merged 1 commit from 365-scheduler-http-limits into main 2026-10-25 17:07:39 +00:00
Owner

Closes #365

Most of #365 was already fixed by #781 and #321:

  • Both scheduler senders stream the request and never read the reply body: the webhook post and the browser push push. A reply of any size, or one that inflates when decompressed, costs nothing, and no compression is asked for.
  • Each request is held to an overall deadline (webhook.TOTAL 30 s, webpush.TOTAL_SECONDS 20 s). That deadline covers the whole request, not each read, so a receiver sending one byte at a time cannot outlast it.

What was left, and what this does:

  • notifications/webhooks.send_pending gets a time budget (BUDGET, 60 s). It stops starting deliveries once the budget is spent, and the rest wait for the next pass, exactly as run_syncs(budget=…) does. A delivery already under way is never cut off. Before this, a batch of 50 slow receivers could hold the pass for up to 50 × 30 s, delaying every account's reminders, copies and syncs. Now this part of the pass is bounded by the budget plus one delivery.
  • The tests "Done when" asks for, in tests/test_webhooks.py:
    • Slow receiver: a receiver that answers one byte at a time fails the delivery at the deadline, quickly, and records the reason on the row.
    • Endless reply: a reply body that never ends is not read; the delivery succeeds and the test asserts no chunk was pulled. The issue asked for this case to fail "past a cap". Since #781 the body is never read at all, so there is no cap to exceed, and the test checks for that instead.
    • Budget: the pass stops starting deliveries once the budget is spent, and the row it left goes out on the next pass.

Locally: tests/test_webhooks.py, tests/test_hardening_781.py and tests/test_syncs.py all pass.

🤖 Generated with Claude Code

Closes #365 Most of #365 was already fixed by #781 and #321: - Both scheduler senders stream the request and never read the reply body: the webhook `post` and the browser push `push`. A reply of any size, or one that inflates when decompressed, costs nothing, and no compression is asked for. - Each request is held to an overall deadline (`webhook.TOTAL` 30 s, `webpush.TOTAL_SECONDS` 20 s). That deadline covers the whole request, not each read, so a receiver sending one byte at a time cannot outlast it. What was left, and what this does: - **`notifications/webhooks.send_pending` gets a time budget** (`BUDGET`, 60 s). It stops *starting* deliveries once the budget is spent, and the rest wait for the next pass, exactly as `run_syncs(budget=…)` does. A delivery already under way is never cut off. Before this, a batch of 50 slow receivers could hold the pass for up to 50 × 30 s, delaying every account's reminders, copies and syncs. Now this part of the pass is bounded by the budget plus one delivery. - **The tests "Done when" asks for**, in `tests/test_webhooks.py`: - **Slow receiver:** a receiver that answers one byte at a time fails the delivery at the deadline, quickly, and records the reason on the row. - **Endless reply:** a reply body that never ends is not read; the delivery succeeds and the test asserts no chunk was pulled. The issue asked for this case to *fail* "past a cap". Since #781 the body is never read at all, so there is no cap to exceed, and the test checks for that instead. - **Budget:** the pass stops starting deliveries once the budget is spent, and the row it left goes out on the next pass. Locally: `tests/test_webhooks.py`, `tests/test_hardening_781.py` and `tests/test_syncs.py` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Give the webhook pass a time budget, and test the receiver limits (#365)
All checks were successful
CI / Unit tests and coverage (Python 3.14) (push) Successful in 12m56s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 2m39s
CI / Database tests on PostgreSQL (push) Successful in 3m36s
CI / Security audit (push) Successful in 1m4s
CI / Browser tests (Chromium) (push) Successful in 30m33s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m0s
Every Python / Unit tests (Python 3.13) (push) Successful in 12m14s
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 12m18s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m32s
CI / Database tests on PostgreSQL (pull_request) Successful in 3m30s
CI / Security audit (pull_request) Successful in 57s
CI / Browser tests (Chromium) (pull_request) Successful in 30m23s
ca64f7156d
#781 already holds each delivery to the plugin's TOTAL and never reads a
receiver's body, so a slow or huge answer cannot hold one request or fill
memory. What was left is the pass: send_pending started up to fifty
deliveries one after another, each allowed TOTAL seconds, so slow receivers
could still hold every account's reminders, copies and syncs behind them.

send_pending now takes a budget (BUDGET, 60 seconds) and stops starting
deliveries once it is spent, as run_syncs does; the rest wait for the next
pass. Tests cover a receiver that dribbles its answer past the deadline, a
reply body that never ends, and the budget.

Closes #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tiagoagueda deleted branch 365-scheduler-http-limits 2026-10-25 17:07:39 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo!1050
No description provided.