Skip to content

fix(queue): retry queued requests on transient errors instead of dropping them - #130

Merged
ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/carry-115-queue-retry-transient-errors
Oct 10, 2026
Merged

ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/carry-115-queue-retry-transient-errors

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

This is a carry of #115 (Erik's work) rebased onto current master, resolving the single conflict where #118 added persistqueue_path to RequestQueue.__init__ at the same site where #115 adds RETRY_STATUS_CODES. Resolution keeps both:

RETRY_STATUS_CODES = {429, 500, 502, 503, 504}

def __init__(self, client: ActivityWatchClient, persistqueue_path: Optional[str] = None) -> None:

All commits are authored by @ErikBjare. Erik: feel free to close this in favour of rebasing #115 yourself — this carry exists only to unblock #127 and #128 which stack on this base.

Why carry instead of waiting on #115: The conflict has been unresolved for 4 days since I noted it on #115 (#115 comment 2026-10-02). A fork carry lets #127/#128 land without further delay while keeping the option to supersede this PR with a direct rebase.

Closes #115 (if merged; Erik may prefer to merge #115 directly instead).

Stack:

Fixes #32 (partially — #127 handles the merge half; #128 handles batch insert).

Problem

On master, RequestQueue permanently drops any queued heartbeat that gets an HTTP error, including 500 and 503. The retry-on-500 branch can never run: if e.response and ... is always false, because requests.Response.__bool__ returns False for every non-2xx status. A ConnectionError raised mid-dispatch (server died after connect) is also dropped, since only ConnectTimeout was caught. aw-server sends 503 when the heartbeat lock times out, so a briefly busy server silently loses watcher data.

Reproduce

python3 -c "import requests; r = requests.Response(); r.status_code = 503; print(bool(r))"   # False

Run the new tests against master's client.py:

git checkout origin/master -- aw_client/client.py
pytest -q tests/test_requestqueue.py   # 7 failed, 5 passed
git checkout HEAD -- aw_client/client.py
pytest -q tests/test_requestqueue.py   # 12 passed

Six of the seven failures are behavioural: each retryable status (429/500/502/503/504) and a connection error leave the queue empty. The seventh is an ImportError for the new _retry_delay.

What the tests cover

  • 429/500/502/503/504: the request stays queued, then is delivered once the server recovers.
  • 400: the request is dropped so a bad payload can't block the queue.
  • ConnectionError: the request stays queued and the queue goes back to reconnecting.
  • Retry-After: delta-seconds are honored, capped at 60s and floored at 0.5s; the HTTP-date form falls back to the default.

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Changes how the request queue handles server errors.

The PR appears safe to merge.

What we checked:

  • Dispatch tests use separate queues: _fresh_queue no longer drains the shared queue. Each caller supplies tmp_path, which keeps its queued requests separate.

Summary

Queued requests now survive connection failures, timeouts, and HTTP 429/500/502/503/504 responses. Retries use a bounded delay and honor numeric Retry-After values.

  • Both previous findings are fixed: the new dispatch tests use separate queues, and dropped requests keep their exception details.
  • No new actionable issues were found in the changes since the previous review.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Read queued request] --> B[Send request]
  B --> C{Result}
  C -->|Success| D[Remove request]
  C -->|Connection failure or timeout| E[Keep request and reconnect]
  C -->|HTTP 429/500/502/503/504| F[Keep request and wait before retry]
  C -->|Other failure| G[Log exception and remove request]
Loading

Reviews (2) · Last reviewed commit: "fix(queue): isolate retry tests' queue, ..."

Comment thread tests/test_requestqueue.py Outdated
Comment thread aw_client/client.py Outdated
TimeToBuildBob added a commit to TimeToBuildBob/aw-client that referenced this pull request Oct 6, 2026
…quests

- tests: back each new dispatch test with its own tmp_path persistqueue
  instead of draining the shared on-disk queue
- client: use logger.exception for non-retryable request errors so the
  failure reason is logged, not just the payload

Addresses Greptile review on ActivityWatch#130.

Git-Session-Id: 4948e2f0-e57b-5a82-899b-c1b20628051d
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Both review findings are fixed in 82a7736 and Greptile now reports 5/5 with no new actionable issues. Seven checks have passed; the macOS test is still queued. No further code change or review round is needed. Merge remains with a maintainer (Bob has pull-only access); once CI finishes, this carry is ready for your review/merge or superseding with a direct rebase of #115.

ErikBjare and others added 3 commits October 10, 2026 10:36
…ping them

The request queue exists to preserve heartbeats when the server is
unavailable, but _dispatch_request's error classification defeated it:

- HTTP 503 (sent by aw-server when the heartbeat lock times out, e.g.
  while the server is slow) fell into the 'Unknown error, not retrying'
  branch, permanently discarding the queued request.
- The existing 400/500 branches were dead code: Response.__bool__
  returns Response.ok, which is False for any error status, so
  'if e.response and ...' never matched and every HTTP error was
  dropped - including the 500s the code claimed to retry.
- A plain ConnectionError mid-dispatch (server died after connect) also
  hit the drop branch, and since 'connected' stayed True the dispatch
  loop kept popping and discarding requests one by one - draining the
  entire on-disk queue during a server outage.

Now transient errors (ConnectionError/Timeout, HTTP 429/500/502/503/504)
leave the request at the head of the persistqueue for a later retry,
exactly like ConnectTimeout already did; only permanent client errors
(e.g. HTTP 400 bad payload) are dropped so they can't wedge the queue.
Connection errors also mark the queue disconnected so the run loop goes
back to reconnecting.

Retrying is safe for heartbeats: a duplicate of an already-processed
heartbeat merges into the last event as a no-op, and FIFO head-retry
preserves the ordering that merging requires (dropping requests from the
middle of the stream broke merge chains, fragmenting events).

Git-Session-Id: e99c
Use the Retry-After header (delta-seconds form, floored at 0.5s and
capped at 60s) for the retry delay on 429/503-style responses, and use
the stop-aware wait() instead of sleep() so a long delay can't block
shutdown.

Git-Session-Id: e99c
…quests

- tests: back each new dispatch test with its own tmp_path persistqueue
  instead of draining the shared on-disk queue
- client: use logger.exception for non-retryable request errors so the
  failure reason is logged, not just the payload

Addresses Greptile review on ActivityWatch#130.

Git-Session-Id: 4948e2f0-e57b-5a82-899b-c1b20628051d
@TimeToBuildBob
TimeToBuildBob force-pushed the fix/carry-115-queue-retry-transient-errors branch from 82a7736 to 65148ab Compare October 10, 2026 10:38
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Rebased onto current master after #124 and #125 landed.

Conflict was only in tests/test_requestqueue.py (both sides appended tests). Resolution keeps master's wait_for_queue_empty / disk-full coverage and this PR's retry tests. aw_client/client.py auto-merged: transient retry + Retry-After, bool add_request, and wait_for_queue_empty are all present.

Local: poetry run pytest tests/test_requestqueue.py — 18 passed. New head is 65148ab. Merge still needs a maintainer (pull-only); #115 can still supersede this carry.

@ErikBjare
ErikBjare merged commit 94717f0 into ActivityWatch:master Oct 10, 2026
7 checks passed
TimeToBuildBob added a commit to TimeToBuildBob/aw-client that referenced this pull request Oct 10, 2026
…vityWatch#125/ActivityWatch#130

- tests: pass tmp_path to _fresh_queue (ActivityWatch#130 made queues isolated per test)
- RequestQueue.__init__: keep _queue_write_failing (ActivityWatch#124) after the batch state
- wait_for_queue_empty (ActivityWatch#125): track the in-flight batch instead of _current
- drop an import left unused by the rebase

Git-Session-Id: 68f2e145-fcff-459d-8e16-c3e43269f5fa
TimeToBuildBob added a commit to TimeToBuildBob/aw-client that referenced this pull request Oct 10, 2026
…ityWatch#125/ActivityWatch#130

- send an empty heartbeat payload instead of dropping it: the post guard
  should only skip an insert reconciled down to nothing (caught by ActivityWatch#125's
  test_wait_for_queue_empty_timeout)
- tests: pass tmp_path to _fresh_queue (ActivityWatch#130 made queues isolated per test)
- tests: silence mypy method-assign on the refuse-then-restore stubs
  (CI runs make typecheck)

Git-Session-Id: 68f2e145-fcff-459d-8e16-c3e43269f5fa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Send heartbeats from queue as batches

2 participants