Redis calls time out instead of hanging a request; notify stops at a dead broker (#841) #1002

Merged
claude-bot merged 3 commits from fix/841-redis-timeouts into main 2026-10-01 20:16:57 +00:00
Collaborator

Two of #841's follow-ups. The blocking-loop part was fixed in #850 and is not touched here. Refs #841 (not closing it: see the end).

1. Redis clients have timeouts

A paused or unreachable Redis used to leave the request that touched it waiting for ever. It didn't block the event loop, but it never answered.

  • app/redis_pool.py now holds the timeouts in one place: 2 s to connect, 10 s for a reply. The shared pool uses them, and a new adhoc_client() replaces every hand-built aioredis.from_url(...) (/ready, the names cache, the relationship-proposal helpers, the workbench pool, the planning tasks). The sync pool in bot_pubsub uses them too.
  • Why 10 s for a reply and not 2 s. On an async client the wait is measured on the event loop, so a loop that stalls for a few seconds under load (#841 measured such stalls) counts against it. Session lookups don't catch Redis errors, so a short timeout would turn a stall into an error on every signed-in request. Ten seconds still ends a request against a hung Redis.
  • Blocking reads keep no read timeout. The one in the backend is the live recording-status stream (get_message(timeout=30)), which now uses blocking_client(). The backend has no consumer of the bot-notify stream; it only adds to it.
  • A timeout takes the same path a Redis outage already does at each site: /ready answers 503 naming Redis (and now closes its client when the ping fails), the cache helpers fall through, and the sites that raised still raise.
  • A guard test fails if any module under app/ builds a Redis client from the URL without the timeouts.

2. notify() stops at a dead broker

With the broker unreachable, each recipient's publish cost its own timeout, one after another. Now the first connectivity failure in a notify() call skips the rest of that batch and logs once how many were skipped.

  • The in-app rows are written before any publish, so everyone still gets their notification in the app.
  • A failure that isn't connectivity (one bad payload) doesn't stop the others.
  • Skipped recipients are not retried, which is how the one that failed was already treated.

Not in this PR

The backend's mem_limit and uvicorn's worker health-check timeout, which are the owner's call. The worker kills #841 was opened for have not been seen since the CI runner's capacity was raised; #841 stays open for that decision.

Checked

  • Full backend suite: 6594 passed, 14 skipped. After merging main: tests/test_redis_timeouts.py, tests/export, tests/wiki, 616 passed.
  • ruff 0.4.4 format and check clean; changelog check OK.
  • No migration, no bot API change.

🤖 Generated with Claude Code

Two of #841's follow-ups. The blocking-loop part was fixed in #850 and is not touched here. Refs #841 (not closing it: see the end). ## 1. Redis clients have timeouts A paused or unreachable Redis used to leave the request that touched it waiting for ever. It didn't block the event loop, but it never answered. - `app/redis_pool.py` now holds the timeouts in one place: **2 s to connect, 10 s for a reply**. The shared pool uses them, and a new `adhoc_client()` replaces every hand-built `aioredis.from_url(...)` (`/ready`, the names cache, the relationship-proposal helpers, the workbench pool, the planning tasks). The sync pool in `bot_pubsub` uses them too. - **Why 10 s for a reply and not 2 s.** On an async client the wait is measured on the event loop, so a loop that stalls for a few seconds under load (#841 measured such stalls) counts against it. Session lookups don't catch Redis errors, so a short timeout would turn a stall into an error on every signed-in request. Ten seconds still ends a request against a hung Redis. - **Blocking reads keep no read timeout.** The one in the backend is the live recording-status stream (`get_message(timeout=30)`), which now uses `blocking_client()`. The backend has no consumer of the bot-notify stream; it only adds to it. - A timeout takes the same path a Redis outage already does at each site: `/ready` answers 503 naming Redis (and now closes its client when the ping fails), the cache helpers fall through, and the sites that raised still raise. - A guard test fails if any module under `app/` builds a Redis client from the URL without the timeouts. ## 2. `notify()` stops at a dead broker With the broker unreachable, each recipient's publish cost its own timeout, one after another. Now the first connectivity failure in a `notify()` call skips the rest of that batch and logs once how many were skipped. - The in-app rows are written before any publish, so everyone still gets their notification in the app. - A failure that isn't connectivity (one bad payload) doesn't stop the others. - Skipped recipients are not retried, which is how the one that failed was already treated. ## Not in this PR The backend's `mem_limit` and uvicorn's worker health-check timeout, which are the owner's call. The worker kills #841 was opened for have not been seen since the CI runner's capacity was raised; #841 stays open for that decision. ## Checked - Full backend suite: 6594 passed, 14 skipped. After merging main: `tests/test_redis_timeouts.py`, `tests/export`, `tests/wiki`, 616 passed. - ruff 0.4.4 format and check clean; changelog check OK. - No migration, no bot API change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into fix/841-redis-timeouts
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 38s
CI / Feedback site tests (pull_request) Successful in 52s
CI / Bot/backend version sync (pull_request) Successful in 55s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m4s
CI / Does this change touch the recording path? (pull_request) Successful in 40s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Docker image build (pull_request) Successful in 1m51s
CI / Bot tests and audit (pull_request) Successful in 2m10s
CI / Frontend (shard 1/2) (pull_request) Successful in 11m0s
CI / Frontend (shard 2/2) (pull_request) Successful in 14m11s
CI / End-to-end journeys (phone) (pull_request) Successful in 9m47s
CI / End-to-end journeys (keyboard) (pull_request) Successful in 11m31s
CI / End-to-end journeys (desktop) (pull_request) Successful in 11m43s
CI / Backend migration, tests, and audit (pull_request) Successful in 50m42s
c4d82aec11
Sign in to join this conversation.
No description provided.