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
18 changes: 18 additions & 0 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 @@ -16,6 +17,12 @@
_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
# 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"})


class APIError(Exception):
def __init__(self, status_code: int, detail: str):
Expand Down Expand Up @@ -83,6 +90,14 @@ 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)
Expand Down Expand Up @@ -139,6 +154,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
116 changes: 116 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import uuid

import httpx
import pytest
import respx
Expand Down Expand Up @@ -117,3 +119,117 @@ def test_context_manager_closes_client(self):
with DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key") as client:
assert client._client is not None
assert client._client.is_closed


class TestIdempotencyKey:
"""
Writes carry an Idempotency-Key so a retry cannot duplicate a record.

The API replays the original response for a repeated key rather than running
the operation again: https://docs.dualentry.com/developers/release-notes/2026-08-12
"""

BASE = "https://api.dualentry.com/public/v2"

@pytest.fixture
def no_backoff(self, monkeypatch):
"""Collapse the retry backoff so retry tests stay fast."""
monkeypatch.setattr("dualentry_cli.client._RETRY_DELAYS", [0, 0, 0])

@staticmethod
def _client(*, retry=False):
from dualentry_cli.client import DualEntryClient

return DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key", retry=retry)

@pytest.mark.parametrize(
("method", "call"),
[
("post", lambda c: c.post("/invoices/", json={"customer_id": 1})),
("put", lambda c: c.put("/invoices/1/", json={"memo": "x"})),
("patch", lambda c: c.patch("/customer-payments/1/", json={"memo": "x"})),
("delete", lambda c: c.delete("/invoices/1/")),
],
)
@respx.mock
def test_write_methods_send_an_idempotency_key(self, method, call):
route = getattr(respx, method)(url__startswith=self.BASE).mock(return_value=httpx.Response(200, json={"ok": True}))

call(self._client())

key = route.calls[0].request.headers.get("Idempotency-Key")
assert key is not None, f"{method.upper()} must send an Idempotency-Key"
# Documented as "a unique value (a UUID works well)", max length 255.
assert uuid.UUID(key)
assert len(key) <= 255

@respx.mock
def test_get_does_not_send_an_idempotency_key(self):
route = respx.get(f"{self.BASE}/invoices/").mock(return_value=httpx.Response(200, json={"items": [], "count": 0}))

self._client().get("/invoices/")

assert "Idempotency-Key" not in route.calls[0].request.headers

@pytest.mark.usefixtures("no_backoff")
@respx.mock
def test_retry_reuses_the_same_key_across_attempts(self):
"""The whole point: a retried POST must not create a second record."""
route = respx.post(f"{self.BASE}/invoices/").mock(
side_effect=[
httpx.Response(502, text="bad gateway"),
httpx.Response(201, json={"internal_id": 1}),
]
)

data = self._client(retry=True).post("/invoices/", json={"customer_id": 1})

assert data == {"internal_id": 1}
assert route.call_count == 2
keys = {c.request.headers["Idempotency-Key"] for c in route.calls}
assert len(keys) == 1, f"retry must reuse the original key, got {keys}"

@pytest.mark.usefixtures("no_backoff")
@respx.mock
def test_every_retry_attempt_carries_the_key(self):
from dualentry_cli.client import APIError

route = respx.post(f"{self.BASE}/invoices/").mock(return_value=httpx.Response(502, text="bad gateway"))

with pytest.raises(APIError):
self._client(retry=True).post("/invoices/", json={"customer_id": 1})

# 4, not 3: the loop runs _MAX_RETRIES times and then issues one more
# request after it. That off-by-one is tracked separately; it is harmless
# here precisely because every attempt replays the same key.
assert route.call_count == 4
keys = {c.request.headers["Idempotency-Key"] for c in route.calls}
assert len(keys) == 1, f"every attempt must reuse one key, got {keys}"

@respx.mock
def test_separate_requests_use_different_keys(self):
route = respx.post(f"{self.BASE}/invoices/").mock(return_value=httpx.Response(201, json={"internal_id": 1}))
client = self._client()

client.post("/invoices/", json={"customer_id": 1})
client.post("/invoices/", json={"customer_id": 2})

keys = [c.request.headers["Idempotency-Key"] for c in route.calls]
assert keys[0] != keys[1], "each logical request needs its own key"

@respx.mock
def test_caller_supplied_key_is_not_overwritten(self):
route = respx.post(f"{self.BASE}/invoices/").mock(return_value=httpx.Response(201, json={"internal_id": 1}))

self._client()._request("POST", "/invoices/", json={}, headers={"Idempotency-Key": "caller-supplied-key"})

assert route.calls[0].request.headers["Idempotency-Key"] == "caller-supplied-key"

@respx.mock
def test_key_is_sent_even_when_retry_is_disabled(self):
"""Protects against retries outside our control (proxies, user re-runs are new keys)."""
route = respx.post(f"{self.BASE}/invoices/").mock(return_value=httpx.Response(201, json={"internal_id": 1}))

self._client(retry=False).post("/invoices/", json={})

assert "Idempotency-Key" in route.calls[0].request.headers