Skip to content

Commit 3c7bc80

Browse files
authored
Merge pull request #17 from JeyKip/fix/retry-idempotency-key
Send an idempotency key on write requests
2 parents fc41d33 + c278fe3 commit 3c7bc80

2 files changed

Lines changed: 500 additions & 18 deletions

File tree

‎src/dualentry_cli/client.py‎

Lines changed: 116 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import sys
77
import time
8+
import uuid
89
from typing import Any
910

1011
import httpx
@@ -13,8 +14,72 @@
1314

1415
# Status codes that should be retried (transient errors)
1516
_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
16-
_MAX_RETRIES = 3
17+
# Transient transport failures. Deliberately excludes LocalProtocolError,
18+
# UnsupportedProtocol, DecodingError and TooManyRedirects: those fail the same
19+
# way every time, so retrying only delays the error the user needs to see.
20+
_RETRYABLE_EXCEPTIONS = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
1721
_RETRY_DELAYS = [1, 2, 4] # Exponential backoff: 1s, 2s, 4s
22+
_MAX_RETRIES = len(_RETRY_DELAYS)
23+
24+
# The API replays the original response for a repeated Idempotency-Key instead of
25+
# running the operation again, so a retried write cannot create a duplicate record.
26+
# https://docs.dualentry.com/developers/release-notes/2026-08-12
27+
_IDEMPOTENCY_HEADER = "Idempotency-Key"
28+
_IDEMPOTENCY_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
29+
30+
# 429 and the in-flight 409 both report exactly how long to wait.
31+
# https://docs.dualentry.com/developers/guides/rate-limiting
32+
_RETRY_AFTER_HEADER = "Retry-After"
33+
34+
_MAX_RETRY_AFTER = 60
35+
36+
37+
def _retry_after_seconds(response: httpx.Response) -> int | None:
38+
"""Seconds from the Retry-After header, or None if absent or unusable."""
39+
raw = response.headers.get(_RETRY_AFTER_HEADER)
40+
if raw is None:
41+
return None
42+
try:
43+
# RFC 9110 delay-seconds is a non-negative integer
44+
seconds = int(raw.strip())
45+
except (TypeError, ValueError):
46+
return None
47+
return seconds if seconds >= 0 else None
48+
49+
50+
def _is_retryable(response: httpx.Response) -> bool:
51+
"""
52+
Whether this response should be retried with the same idempotency key.
53+
54+
409 means two different things, told apart by Retry-After:
55+
with the header the first request is still running and we should retry;
56+
without it the original response was too large to store, the write did not run again
57+
https://docs.dualentry.com/developers/guides/idempotency-and-write-validation
58+
"""
59+
if response.status_code == 409:
60+
return _RETRY_AFTER_HEADER in response.headers
61+
return response.status_code in _RETRYABLE_STATUS_CODES
62+
63+
64+
def _server_detail(response: httpx.Response) -> str:
65+
try:
66+
payload = response.json()
67+
except Exception:
68+
return response.text.strip()
69+
errors = payload.get("errors", payload) if isinstance(payload, dict) else payload
70+
if isinstance(errors, dict):
71+
messages = []
72+
for field, msgs in errors.items():
73+
if isinstance(msgs, list):
74+
messages.extend(str(msg) for msg in msgs)
75+
else:
76+
messages.append(f"{field}: {msgs}")
77+
return "; ".join(messages)
78+
return str(errors)
79+
80+
81+
def _explain(message: str, detail: str) -> str:
82+
return f"{message} Server said: {detail}" if detail else message
1883

1984

2085
class APIError(Exception):
@@ -46,7 +111,7 @@ def from_env(cls, api_url: str, *, retry: bool = False) -> DualEntryClient:
46111
raise ValueError(msg)
47112
return cls(api_url=api_url, api_key=api_key, retry=retry)
48113

49-
def _handle_response(self, response: httpx.Response) -> dict:
114+
def _handle_response(self, response: httpx.Response, *, sent_idempotency_key: bool = False) -> dict:
50115
status = response.status_code
51116
if status == 401:
52117
raise APIError(401, "API key is invalid or expired. Run: dualentry auth login")
@@ -61,10 +126,30 @@ def _handle_response(self, response: httpx.Response) -> dict:
61126
except Exception:
62127
errors = response.text
63128
raise APIError(422, f"Validation error: {errors}")
129+
if status == 409:
130+
detail = _server_detail(response)
131+
if _RETRY_AFTER_HEADER in response.headers:
132+
wait = _retry_after_seconds(response)
133+
when = f"Retry in {wait}s with the same key." if wait is not None else "Retry shortly with the same key."
134+
raise APIError(409, _explain(f"The first request with this idempotency key is still being processed. {when}", detail))
135+
if sent_idempotency_key:
136+
raise APIError(
137+
409,
138+
_explain(
139+
"The original response is too large to replay (over 256 KB). The write was not repeated - check whether the record already exists before sending it again.",
140+
detail,
141+
),
142+
)
143+
raise APIError(409, detail or "Conflict.")
64144
if status == 429:
145+
wait = _retry_after_seconds(response)
146+
if wait is not None:
147+
raise APIError(429, f"Rate limited. Retry after {wait}s.")
65148
raise APIError(429, "Rate limited. Please wait and try again.")
66149
if status >= 500:
67-
raise APIError(status, f"Server error ({status}). The API may be temporarily unavailable.")
150+
wait = _retry_after_seconds(response)
151+
when = f" The server asked to retry after {wait}s." if wait is not None else ""
152+
raise APIError(status, f"Server error ({status}). The API may be temporarily unavailable.{when}")
68153
if status >= 400:
69154
try:
70155
detail = response.json()
@@ -83,30 +168,40 @@ def _handle_response(self, response: httpx.Response) -> dict:
83168
return response.json()
84169

85170
def _request(self, method: str, path: str, **kwargs) -> dict:
171+
method = method.upper()
172+
keyed = method in _IDEMPOTENCY_METHODS
173+
if keyed:
174+
# One key per logical request, deliberately generated here rather than
175+
# per attempt: reusing it across retries is what makes a retry safe.
176+
headers = dict(kwargs.pop("headers", None) or {})
177+
headers.setdefault(_IDEMPOTENCY_HEADER, str(uuid.uuid4()))
178+
kwargs["headers"] = headers
179+
86180
if not self._retry:
87181
response = self._client.request(method, path, **kwargs)
88-
return self._handle_response(response)
182+
return self._handle_response(response, sent_idempotency_key=keyed)
89183

90184
# Retry logic with visible feedback
91-
last_error = None
92-
for attempt in range(_MAX_RETRIES):
185+
for attempt, backoff in enumerate(_RETRY_DELAYS):
186+
retry_after = None
93187
try:
94188
response = self._client.request(method, path, **kwargs)
95-
if response.status_code not in _RETRYABLE_STATUS_CODES:
96-
return self._handle_response(response)
97-
# Retryable error - will retry
98-
last_error = APIError(response.status_code, f"Temporary error ({response.status_code})")
99-
except httpx.RequestError as e:
100-
last_error = e
101-
102-
if attempt < _MAX_RETRIES - 1:
103-
delay = _RETRY_DELAYS[attempt]
104-
print(f"\033[33mRetrying in {delay}s... (attempt {attempt + 2}/{_MAX_RETRIES})\033[0m", file=sys.stderr)
105-
time.sleep(delay)
189+
if not _is_retryable(response):
190+
return self._handle_response(response, sent_idempotency_key=keyed)
191+
retry_after = _retry_after_seconds(response)
192+
if retry_after is not None and retry_after > _MAX_RETRY_AFTER:
193+
return self._handle_response(response, sent_idempotency_key=keyed)
194+
except _RETRYABLE_EXCEPTIONS:
195+
pass
196+
197+
# every retry waits, including the one after the loop
198+
delay = retry_after if retry_after is not None else backoff
199+
print(f"\033[33mRetrying in {delay}s... (attempt {attempt + 2}/{_MAX_RETRIES + 1})\033[0m", file=sys.stderr)
200+
time.sleep(delay)
106201

107202
# Final attempt
108203
response = self._client.request(method, path, **kwargs)
109-
return self._handle_response(response)
204+
return self._handle_response(response, sent_idempotency_key=keyed)
110205

111206
def get(self, path: str, params: dict[str, Any] | None = None) -> dict:
112207
return self._request("GET", path, params=params)
@@ -139,6 +234,9 @@ def post(self, path: str, json: dict[str, Any] | None = None) -> dict:
139234
def put(self, path: str, json: dict[str, Any] | None = None) -> dict:
140235
return self._request("PUT", path, json=json)
141236

237+
def patch(self, path: str, json: dict[str, Any] | None = None) -> dict:
238+
return self._request("PATCH", path, json=json)
239+
142240
def delete(self, path: str) -> dict:
143241
return self._request("DELETE", path)
144242

0 commit comments

Comments
 (0)