55import os
66import sys
77import time
8+ import uuid
89from typing import Any
910
1011import httpx
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
2085class 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