Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 72 additions & 10 deletions src/dualentry_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import sys
import time
import uuid
from typing import Any

import httpx
Expand All @@ -13,9 +14,50 @@

# Status codes that should be retried (transient errors)
_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
# Transient transport failures. Deliberately excludes LocalProtocolError,
# UnsupportedProtocol, DecodingError and TooManyRedirects: those fail the same
# way every time, so retrying only delays the error the user needs to see.
_RETRYABLE_EXCEPTIONS = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
_MAX_RETRIES = 3
_RETRY_DELAYS = [1, 2, 4] # Exponential backoff: 1s, 2s, 4s

# The API replays the original response for a repeated Idempotency-Key instead of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing _RETRYABLE_STATUS_CODES constant: The refactored _is_retryable() function at line 36 references _RETRYABLE_STATUS_CODES but it is never defined in the diff. The code will raise NameError at runtime when a non-409 retryable status (502, 503, 429) is encountered. Define the constant before line 20.

Suggested change
# The API replays the original response for a repeated Idempotency-Key instead of
# The API replays the original response for a repeated Idempotency-Key instead of
# running the operation again, so a retried write cannot create a duplicate record.
# https://docs.dualentry.com/developers/release-notes/2026-08-12
_IDEMPOTENCY_HEADER = "Idempotency-Key"
_IDEMPOTENCY_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# 429 and the in-flight 409 both report exactly how long to wait.
# https://docs.dualentry.com/developers/guides/rate-limiting
_RETRY_AFTER_HEADER = "Retry-After"
_RETRYABLE_STATUS_CODES = frozenset({502, 503, 429})

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mentioned constant was already in place, so it wasn't included into the PR.

# running the operation again, so a retried write cannot create a duplicate record.
# https://docs.dualentry.com/developers/release-notes/2026-08-12
_IDEMPOTENCY_HEADER = "Idempotency-Key"
_IDEMPOTENCY_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})

# 429 and the in-flight 409 both report exactly how long to wait.
# https://docs.dualentry.com/developers/guides/rate-limiting
_RETRY_AFTER_HEADER = "Retry-After"


def _retry_after_seconds(response: httpx.Response) -> int | None:
"""Seconds from the Retry-After header, or None if absent or unusable."""
raw = response.headers.get(_RETRY_AFTER_HEADER)
if raw is None:
return None
try:
# RFC 9110 delay-seconds is a non-negative integer
seconds = int(raw.strip())
except (TypeError, ValueError):
return None
return seconds if seconds >= 0 else None


def _is_retryable(response: httpx.Response) -> bool:
"""
Whether this response should be retried with the same idempotency key.

409 means two different things, told apart by Retry-After:
with the header the first request is still running and we should retry;
without it the original response was too large to store, the write did not run again
https://docs.dualentry.com/developers/guides/idempotency-and-write-validation
"""
if response.status_code == 409:
return _retry_after_seconds(response) is not None
return response.status_code in _RETRYABLE_STATUS_CODES


class APIError(Exception):
def __init__(self, status_code: int, detail: str):
Expand Down Expand Up @@ -61,7 +103,17 @@ def _handle_response(self, response: httpx.Response) -> dict:
except Exception:
errors = response.text
raise APIError(422, f"Validation error: {errors}")
if status == 409:
wait = _retry_after_seconds(response)
if wait is not None:
raise APIError(409, f"The first request with this idempotency key is still being processed. Retry in {wait:g}s with the same key.")
raise APIError(
409, "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."
)
if status == 429:
wait = _retry_after_seconds(response)
if wait is not None:
raise APIError(429, f"Rate limited. Retry after {wait:g}s.")
raise APIError(429, "Rate limited. Please wait and try again.")
if status >= 500:
raise APIError(status, f"Server error ({status}). The API may be temporarily unavailable.")
Expand All @@ -83,26 +135,33 @@ def _handle_response(self, response: httpx.Response) -> dict:
return response.json()

def _request(self, method: str, path: str, **kwargs) -> dict:
method = method.upper()
if method in _IDEMPOTENCY_METHODS:
# One key per logical request, deliberately generated here rather than
# per attempt: reusing it across retries is what makes a retry safe.
headers = dict(kwargs.pop("headers", None) or {})
headers.setdefault(_IDEMPOTENCY_HEADER, str(uuid.uuid4()))
kwargs["headers"] = headers

if not self._retry:
response = self._client.request(method, path, **kwargs)
return self._handle_response(response)

# Retry logic with visible feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Off-by-one in retry loop: The loop at line 148 runs _MAX_RETRIES times (3 iterations: attempts 0, 1, 2), then line 165 unconditionally issues a 4th request after the loop exits. This produces 4 total attempts instead of the advertised _MAX_RETRIES=3. Additionally, the stderr message at line 163 prints attempt + 2 and _MAX_RETRIES + 1 (producing "attempt 2/4"), but the final 4th request after the loop has no message. The user sees "Retrying" three times then a silent 4th attempt. Fix: move the final request inside the loop and return after each successful response; remove the unconditional request after line 164.

Suggested change
# Retry logic with visible feedback
# Retry logic with visible feedback
last_error = None
for attempt in range(_MAX_RETRIES):
retry_after = None
try:
response = self._client.request(method, path, **kwargs)
if not _is_retryable(response):
return self._handle_response(response)
retry_after = _retry_after_seconds(response)
# Retryable error - will retry
last_error = APIError(response.status_code, f"Temporary error ({response.status_code})")
except httpx.RequestError as e:
last_error = e
if attempt < _MAX_RETRIES - 1:
delay = retry_after if retry_after is not None else _RETRY_DELAYS[attempt]
print(f"\033[33mRetrying in {delay:g}s... (attempt {attempt + 2}/{_MAX_RETRIES})\033[0m", file=sys.stderr)
time.sleep(delay)
# Final attempt
response = self._client.request(method, path, **kwargs)
return self._handle_response(response)

last_error = None
for attempt in range(_MAX_RETRIES):
retry_after = None
try:
response = self._client.request(method, path, **kwargs)
if response.status_code not in _RETRYABLE_STATUS_CODES:
if not _is_retryable(response):
return self._handle_response(response)
# Retryable error - will retry
last_error = APIError(response.status_code, f"Temporary error ({response.status_code})")
except httpx.RequestError as e:
last_error = e
retry_after = _retry_after_seconds(response)
except _RETRYABLE_EXCEPTIONS:
pass

if attempt < _MAX_RETRIES - 1:
delay = _RETRY_DELAYS[attempt]
print(f"\033[33mRetrying in {delay}s... (attempt {attempt + 2}/{_MAX_RETRIES})\033[0m", file=sys.stderr)
time.sleep(delay)
# every retry waits, including the one after the loop
delay = retry_after if retry_after is not None else _RETRY_DELAYS[attempt]
print(f"\033[33mRetrying in {delay:g}s... (attempt {attempt + 2}/{_MAX_RETRIES + 1})\033[0m", file=sys.stderr)
time.sleep(delay)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent retry messaging and logic: Line 163 prints "attempt {attempt + 2}/{_MAX_RETRIES + 1}" (printing 2/4), but this message is shown only when attempt < _MAX_RETRIES - 1 is true (line 162). After the loop exits (all 3 iterations done), line 165 issues the 4th request without printing a message or waiting. The message at line 163 should print "{attempt + 2}/{_MAX_RETRIES}" to match the fixed loop logic; see prior comment.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the comment below I concluded that the 4th attempt was intentional, so left it unchanged and just fixed delay to honor the Retry-After header and the exponential backoff value used by default. Honestly, we can simply remove the "Final attempt" block and stay with only 3 attempts to retry, or increase the value of _MAX_RETRIES to 4 (I will do this). This would keep current behavior and make the code a bit cleaner.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to do it the way I wrote above, but in the end it made the code more complex instead of cleaner, so I left _MAX_RETRIES = 3 and the "Final attempt" block as they are.

One more thing I changed in this method is what exactly we catch. It was except httpx.RequestError, which is basically everything, including errors that can never succeed on a second attempt: a wrong scheme in the URL (UnsupportedProtocol), LocalProtocolError, DecodingError, TooManyRedirects. So if somebody sets a wrong DUALENTRY_API_URL, the CLI was sending 4 requests and sleeping 1+2+4 seconds before showing an error that was already known after the first one. Now we retry only timeouts, network errors and RemoteProtocolError, and everything else is reported immediately. Both lists are covered with tests.


# Final attempt
response = self._client.request(method, path, **kwargs)
Expand Down Expand Up @@ -139,6 +198,9 @@ def post(self, path: str, json: dict[str, Any] | None = None) -> dict:
def put(self, path: str, json: dict[str, Any] | None = None) -> dict:
return self._request("PUT", path, json=json)

def patch(self, path: str, json: dict[str, Any] | None = None) -> dict:
return self._request("PATCH", path, json=json)

def delete(self, path: str) -> dict:
return self._request("DELETE", path)

Expand Down
Loading