diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index 59e8178..843f44d 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -5,6 +5,7 @@ import os import sys import time +import uuid from typing import Any import httpx @@ -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): @@ -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) @@ -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) diff --git a/tests/test_client.py b/tests/test_client.py index a26ba2e..5af47d5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,5 @@ +import uuid + import httpx import pytest import respx @@ -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