Repository navigation
fix(tui): back off the unread route when the server answers 404 - #174
allenhutchison wants to merge 1 commit into
Conversation
A server without GET /api/v1/groups/unread made every rail poll a full 200 plus a failing request, with the ETag dropped. A 404 now holds for ten minutes, keeps the ETag and settles a pending refetch_all; one full poll then asks again. 401 and transport errors are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL
Vycari Review (shadow)Shadow round 1 on 0f7855d. This comment is non-gating and needs no response: it is collected to measure agreement with the current reviewer. The round's verdict is the Vycari Review (shadow) check run. Open: P0 0 · P1 1 · P2 0 · confirmed fixed 0 · withdrawn 0 Judge's notes The back-off works for servers that really lack the unread route. One narrow P1 remains: a transient 404 on a route that exists is taken as a final answer. The pending refetch is then recorded as done, and unread markers can stay stale for up to ten minutes. P1 · VR-7f685da2 · A transient 404 on an existing unread route stops unread refresh for ten minutes and answers a pending refetch_allsrc/pepper_cli/tui/poller.py lines 166–176 · symbol RailPoller._fetch_unread · lens fail-open · status new _fetch_unread treats any 404 from GET /api/v1/groups/unread as 'route missing' and sets _unread_retry_at for 600 s. step() then treats that 404 as an answer (complete=True via answered_404), commits seen for every room, and returns full=asked, so a pending refetch_all (the catch-up that re-reads every room's marks after a read on another device) is marked done without any marks having been read. For the next ten minutes, in-window steps never call the unread route (asking is False), so marks that moved on another device are not refreshed until the expiry-forced full poll. Evidence: poller.py:166-176 (404 sets retry deadline and returns None), 120-129 (asking=False in window; answered_404 makes complete True), 145-147 (etag kept and full=asked when complete). api_client.py:3335-3336 documents 404 as the no-route answer only. The PR body says 'Only a 404 from this very request counts', so the transient case is not guarded. No check distinguishes a missing route from a transient 404 (no retry, no body inspection). Severity is limited: the stale state is bounded to ten minutes and only arises when a server returns 404 on an existing route. Failure scenario Server has the route. A read happens on another device, the app calls refetch_all(). The unread request hits a transient 404 (gateway or deploy hiccup, or a server 404 for a reason other than a missing route). step() returns complete with full=asked and the ETag kept. The rail's unread markers and server marks stay as before for up to 600 s; room-list changes during the window update seen without reading unread state. The user sees stale unread markers for up to ten minutes with no indication, and the refetch request is recorded as satisfied. How this was verified (read-only-reasoning) I read the diff of _fetch_unread and step. I did not open the checkout files or the api_client lines the candidate cites. The behaviour is deliberate and documented in the PR, and the only open question is how likely a spurious 404 is. Proving test |
Human overview
Against a server without
GET /api/v1/groups/unread, the rail made a full room-list 200 plus a failing request on every poll and never kept the ETag. A 404 now holds for ten minutes, keeps the ETag so unchanged polls are 304 again, and then one full poll asks once more. 401 and transport errors behave as before.Closes #154
AI reviewer
Change (tui/poller.py,
RailPoller):_fetch_unreadsets_unread_retry_at = monotonic() + UNREAD_MISSING_RETRY_SECONDS(600) on a 404 and clears it on a success. 401 still raises; transport errors still propagate; other statuses still returnNone(asked again next poll, ETag dropped).step: while the back-off is active the unread request is not made (askingis false). A 404 from this very request counts as an answer:completeis true, so the ETag is kept,seenis advanced and a pendingrefetch_allis answered. Once the back-off expires the step is forced full (a 200), so the route is retried even if no room moved. If an expired back-off meets an empty room list, nothing is asked, so the back-off is cleared instead of forcing a 200 on every poll.completeuses_unread_retry_at != retry_at(changed during this request), so a stale deadline cannot excuse a 500 on a retry.Alternative not taken: a terminal "never ask again this session". Ten minutes lets a TUI left open across a server upgrade pick the route up without a restart, at the price of one extra failing request per interval.
Known limits:
_unread_retry_atis written insidestep(worker thread), not incommit, unlike the ETag andseen. Steps do not overlap, and the state is only a "do not ask yet" hint, so a discarded result at worst skips or repeats one request. Moving it intoRailUpdatewould be a wider change. The back-off is per process and in memory.Docs: README unread paragraph and CHANGELOG entry updated.
Verification:
tests/test_tui_poller.py: 404 keeps the ETag and answersrefetch_all, no request during the window, retry after expiry (success and 404), expired back-off with no rooms ends, 500 after a 404 is not an answer. They use a fake clock.uv run ruff format --check,ruff check,ty check src/pepper_cli/clean;uv run pytest --cov --cov-report=json:coverage-summary.json: 2660 passed, 2 skipped.commit, above).Checklist (repo-specific)
uv run ruff format --checkanduv run ruff checkclean (no new lint ignores)uv run ty check src/pepper_cli/cleanuv run pytestpasses; coverage floor in.claude/maintainerd.jsonnot loweredSKILL.md: README and CHANGELOG updated; SKILL.md has no TUI behavior🤖 Generated with Claude Code
https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL


Confidence Score: 5/5
The PR appears safe to merge.Summary
Adds a ten-minute pause after
GET /api/v1/groups/unreadreturns 404.No actionable issues found. allenhutchison explicitly acknowledged that
_unread_retry_atchanges insidestep, rather thancommit, and deferred moving it because steps do not overlap and discarded results only affect request timing. Tests were inspected, not run.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Poll room list] --> B{Unread pause active?} B -->|Yes| C[Skip unread request and keep ETag] B -->|No, deadline expired| D[Force full room-list poll] D --> E{Any rooms?} E -->|No| F[Clear deadline] E -->|Yes| G[Request unread rows] G --> H{Response} H -->|404| I[Pause for ten minutes and keep ETag] H -->|Success| J[Clear deadline and apply unread rows] H -->|Other nonfatal error| K[Drop ETag and retry next poll]Reviews (1) · Last reviewed commit: "fix(tui): back off the unread route when..." · Reviewed by Greptile