Skip to content

Send an idempotency key on write requests - #17

Open
JeyKip wants to merge 1 commit into
dualentry:mainfrom
JeyKip:fix/retry-idempotency-key
Open

Send an idempotency key on write requests#17
JeyKip wants to merge 1 commit into
dualentry:mainfrom
JeyKip:fix/retry-idempotency-key

Conversation

@JeyKip

@JeyKip JeyKip commented Aug 16, 2026

Copy link
Copy Markdown

Summary

The --retry flag repeats failed requests. It repeats all of them, including POST, PUT, PATCH and DELETE.

The problem is that when a request fails with 502 or 504, we do not know what happened on the server. The request may have failed before anything was saved. Or it may have been processed correctly and only the response was lost. We cannot tell the difference from the client side.

If we retry in the second case, we create a second record. For an accounting CLI this means a duplicated invoice or journal entry.

The DualEntry API added idempotency keys on 2026-08-12:

The public API now accepts an Idempotency-Key header so retried requests do not create duplicate records.
Payroll review and API idempotency keys (Aug 12, 2026)

This PR uses that header. Now every write request sends a key. All retries of the same request send the same key. The server then replays the first response instead of doing the work again, so we no longer need to know what happened on the server.

What the API promises

From the endpoint docs, for example create recurring request, update recurring request, delete recurring request and partial update of a customer payment:

  • The header is optional. Maximum length is 255 characters. "A unique value (a UUID works well)."
  • "If the request is repeated with the same key, the original response is replayed instead of the operation running again, so a retry cannot create a duplicate record."
  • Results are replayable for 48 hours.
  • Reusing a key with a different request body returns 422.

The last point is important. Each logical request must get its own key. We cannot reuse one key for several requests.

Changes

In src/dualentry_cli/client.py:

  • _request now adds an Idempotency-Key header for POST, PUT, PATCH and DELETE.
  • GET does not get the header. It does not change anything on the server, so the header has no meaning there.
  • The key is a uuid.uuid4(). It is created once per _request call, before the retry loop. This is the important part. If we created a new key for each attempt, the bug would still be there.
  • The header is sent also when --retry is off. Something else may repeat the request, for example a proxy. We use setdefault, so if a caller passes its own key, we keep it.
  • Added a patch() method. The API documents PATCH for partial updates, but the client could not send one.

Tests

tests/test_client.py had no tests for the retry logic at all. A new class TestIdempotencyKey with 10 cases was added:

Test What it checks
test_write_methods_send_an_idempotency_key POST, PUT, PATCH, DELETE: header is present, is a valid UUID, is not longer than 255 characters
test_get_does_not_send_an_idempotency_key GET sends no header
test_retry_reuses_the_same_key_across_attempts First 502, then 201. Two calls, one key, correct result
test_every_retry_attempt_carries_the_key All attempts fail. Every attempt uses the same key
test_separate_requests_use_different_keys Two POST requests get two different keys
test_caller_supplied_key_is_not_overwritten A key passed by the caller is kept
test_key_is_sent_even_when_retry_is_disabled Header is present when retry=False

A no_backoff fixture sets _RETRY_DELAYS to zeros. Without it the retry tests would wait 1s, 2s and 4s.

Note about the changes

The PR [#16] needs to be merged into main first, and those changes need to be pulled into this branch for the tests to pass. The changes were originally made and tested on top of the fix/ci-dependency-drift branch, then moved to the current branch, which was created from main.

Test plan

  • Unit tests pass (uv run pytest)
  • Linter passes (uv run ruff check .)
  • Manually tested with dualentry <command> (since I do not have a valid API key, I tested in a mocked environment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant