Skip to content

Commit b2f2928

Browse files
committed
fix(queue): retry queued requests on transient errors instead of dropping 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).
1 parent 2c88b87 commit b2f2928

2 files changed

Lines changed: 117 additions & 15 deletions

File tree

aw_client/client.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@ class RequestQueue(threading.Thread):
436436

437437
VERSION = 1 # update this whenever the queue-file format changes
438438

439+
# HTTP statuses that indicate a transient server-side problem, for which
440+
# requests are kept in the queue and retried (dropped on anything else).
441+
# 503 in particular is sent by aw-server when the heartbeat lock times out.
442+
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
443+
439444
def __init__(self, client: ActivityWatchClient) -> None:
440445
threading.Thread.__init__(self, daemon=True)
441446

@@ -515,13 +520,12 @@ def _dispatch_request(self) -> None:
515520

516521
try:
517522
self.client._post(request.endpoint, request.data)
518-
except req.exceptions.ConnectTimeout:
523+
except (req.exceptions.ConnectionError, req.exceptions.Timeout):
519524
# Triggered by:
520525
# - server not running (connection refused)
521526
# - server not responding (timeout)
522-
# Safe to retry according to requests docs:
523-
# https://requests.readthedocs.io/en/latest/api/#requests.ConnectTimeout
524-
527+
# Keep the request in the queue and go back to waiting for the
528+
# server to become available (the run loop reconnects).
525529
self.connected = False
526530
logger.warning(
527531
"Connection refused or timeout, will queue requests until connection is available."
@@ -532,20 +536,29 @@ def _dispatch_request(self) -> None:
532536
sleep(0.5)
533537
return
534538
except req.RequestException as e:
535-
if e.response and e.response.status_code == 400:
536-
# HTTP 400 - Bad request
537-
# Example case: https://github.com/ActivityWatch/activitywatch/issues/815
538-
# We don't want to retry, because a bad payload is likely to fail forever.
539-
logger.error(f"Bad request, not retrying: {request.data}")
540-
elif e.response and e.response.status_code == 500:
541-
# HTTP 500 - Internal server error
542-
# It is possible that the server is in a bad state (and will recover on restart),
543-
# in which case we want to retry. I hope this can never caused by a bad payload.
544-
logger.error(f"Internal server error, retrying: {request.data}")
539+
# NOTE: `e.response is not None` matters: Response.__bool__ is
540+
# False for any non-2xx status, so a plain `if e.response` never
541+
# matches an error response.
542+
status_code = e.response.status_code if e.response is not None else None
543+
if status_code in self.RETRY_STATUS_CODES:
544+
# Transient server-side problem (busy, overloaded, restarting
545+
# or behind a flaky proxy) - the request itself is likely
546+
# fine, so keep it in the queue and retry. Heartbeats are safe
547+
# to replay: a duplicate of an already-processed heartbeat
548+
# merges into the last event as a no-op.
549+
logger.warning(
550+
f"Server error {status_code}, will retry: {request.endpoint}"
551+
)
545552
sleep(0.5)
546553
return
547554
else:
548-
logger.exception(f"Unknown error, not retrying: {request.data}")
555+
# Client errors (e.g. HTTP 400 - bad request, see
556+
# https://github.com/ActivityWatch/activitywatch/issues/815)
557+
# are likely to fail forever, so drop the request instead of
558+
# blocking the queue.
559+
logger.error(
560+
f"Request failed ({status_code}), not retrying: {request.data}"
561+
)
549562
except Exception:
550563
logger.exception(f"Unknown error, not retrying: {request.data}")
551564

tests/test_requestqueue.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
basicConfig(level=DEBUG)
1414

15+
import pytest
1516
import requests
17+
1618
from aw_client.client import RequestQueue
1719

1820

@@ -88,3 +90,90 @@ def create_bucket(self, *args, **kwargs):
8890

8991
assert rq.connected is False
9092
assert client.create_bucket_calls == [(("test-bucket", "test-type"), {})]
93+
94+
95+
def _http_error(status_code: int) -> requests.exceptions.HTTPError:
96+
response = requests.Response()
97+
response.status_code = status_code
98+
return requests.exceptions.HTTPError(response=response)
99+
100+
101+
class FlakyClient(MockClient):
102+
"""Client whose _post raises the given exception until cleared."""
103+
104+
def __init__(self, exc):
105+
super().__init__()
106+
self.exc = exc
107+
self.post_calls = 0
108+
109+
def _post(self, *args, **kwargs):
110+
self.post_calls += 1
111+
if self.exc:
112+
raise self.exc
113+
return requests.Response()
114+
115+
116+
def _fresh_queue(client) -> RequestQueue:
117+
"""Create a RequestQueue and drain requests persisted by earlier runs."""
118+
rq = RequestQueue(client) # type: ignore
119+
while rq._get_next():
120+
rq._task_done()
121+
return rq
122+
123+
124+
@pytest.mark.parametrize("status_code", [429, 500, 502, 503, 504])
125+
def test_dispatch_retries_transient_server_errors(status_code):
126+
"""
127+
Transient server-side errors (e.g. 503 from aw-server's heartbeat-lock
128+
timeout) must keep the request in the queue for a later retry, then
129+
dispatch it once the server recovers.
130+
131+
Also guards against the Response.__bool__ pitfall: `if e.response` is
132+
False for any error status, which used to send every HTTP error down the
133+
"not retrying" path (dropping the request permanently).
134+
"""
135+
client = FlakyClient(_http_error(status_code))
136+
rq = _fresh_queue(client)
137+
rq.connected = True
138+
139+
rq.add_request("buckets/test/heartbeat?pulsetime=10", {"label": "test"})
140+
rq._dispatch_request()
141+
142+
assert client.post_calls == 1
143+
assert rq._get_next() is not None # still queued
144+
145+
client.exc = None # server recovered
146+
rq._dispatch_request()
147+
148+
assert client.post_calls == 2
149+
assert rq._get_next() is None # delivered and popped
150+
151+
152+
def test_dispatch_drops_client_errors():
153+
"""A bad payload (HTTP 400) fails forever and must not block the queue."""
154+
client = FlakyClient(_http_error(400))
155+
rq = _fresh_queue(client)
156+
rq.connected = True
157+
158+
rq.add_request("buckets/test/heartbeat?pulsetime=10", {"label": "bad"})
159+
rq._dispatch_request()
160+
161+
assert client.post_calls == 1
162+
assert rq._get_next() is None # dropped
163+
164+
165+
def test_dispatch_keeps_queue_on_connection_error():
166+
"""
167+
A connection error mid-dispatch (server died after connect) must keep the
168+
request queued and mark the queue disconnected, so the run loop goes back
169+
to reconnecting instead of draining the queue into the void.
170+
"""
171+
client = FlakyClient(requests.exceptions.ConnectionError())
172+
rq = _fresh_queue(client)
173+
rq.connected = True
174+
175+
rq.add_request("buckets/test/heartbeat?pulsetime=10", {"label": "test"})
176+
rq._dispatch_request()
177+
178+
assert rq._get_next() is not None # still queued
179+
assert rq.connected is False

0 commit comments

Comments
 (0)