Skip to content

fix: forward idempotency key on balance creation and document create race (EN-1205)#110

Open
flemzord wants to merge 3 commits into
mainfrom
fix/balance-create-idempotency
Open

fix: forward idempotency key on balance creation and document create race (EN-1205)#110
flemzord wants to merge 3 commits into
mainfrom
fix/balance-create-idempotency

Conversation

@flemzord

Copy link
Copy Markdown
Member

Problem (Medium — M5, M7-balance)

  • M7 (balance)CreateBalance passed "" as the Idempotency-Key to AddMetadataToAccount, so a retried POST /wallets/{id}/balances was not deduplicated by the ledger and could re-apply (clobber) the balance metadata.
  • M5CreateBalance is a check-then-act on account metadata (GetAccount existence check, then AddMetadataToAccount), an undocumented race.

Fix

  • Forward the Idempotency-Key to AddMetadataToAccount so retries of the same request are deduplicated by the ledger — this removes the common-case duplicate/clobber.
  • Document the residual behaviour: two genuinely concurrent first-time creations of the same balance can both pass the existence check, after which the writes are last-write-wins on priority/expiresAt for that single account (no fund movement, no duplicate account).

A fully atomic create would require a conditional metadata write on the ledger side, which the API does not currently expose — called out in the doc comment as the proper follow-up rather than faking a distributed lock here.

Tests

  • TestBalancesCreateForwardsIdempotencyKey: the Idempotency-Key header reaches the ledger call.

Note

This is the balance half of M7 (the wallet half is in #109), grouped here because both changes touch CreateBalance.

From the in-depth repository review.

…race

CreateBalance ignored the Idempotency-Key (passed "" to the ledger), so a
retried POST /wallets/{id}/balances could clobber the balance metadata, and
the check-then-act on account metadata was an undocumented race.

- Forward the Idempotency-Key to AddMetadataToAccount so retries are
  deduplicated by the ledger
- Document the residual concurrent-first-create behaviour (last-write-wins on
  priority/expiresAt for the single account); a fully atomic create would
  need a conditional metadata write the ledger API does not expose

Adds a test asserting the key is forwarded.
@flemzord flemzord requested a review from a team as a code owner June 11, 2026 08:04
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds idempotency support to balance creation. The manager method now accepts an idempotency key, hashes it, and stores it in account metadata to detect and replay responses for retried requests. The handler extracts the key from the request header and forwards it. Tests validate key forwarding, replay behavior, and rejection of duplicate operations with different keys.

Changes

Idempotency Support for Balance Creation

Layer / File(s) Summary
Metadata key constant for idempotency hash
pkg/metadata.go
New exported constant MetadataKeyBalanceIdempotencyKey identifies where the hashed idempotency key is stored in balance account metadata.
Manager idempotency implementation and check-then-act logic
pkg/manager.go
CreateBalance signature updated to accept ik parameter; new hashIdempotencyKey helper computes SHA-256 hash; check-then-act logic hashes incoming key and returns existing balance when stored hash matches; new balance creation stores hashed key in metadata.
Handler extraction and forwarding of idempotency key
pkg/api/handler_balances_create.go
Handler adds transport import and updates CreateBalance call to extract Idempotency-Key header from request and pass it to the manager.
Idempotency behavior test suite
pkg/api/handler_balances_create_test.go
Three test cases validate that idempotency keys are forwarded to the manager, that replayed requests return 201 without duplicate metadata writes, and that different keys do not trigger replay.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Handler
  participant Manager
  participant Metadata
  
  Note over Client,Metadata: Request 1: Create balance with key ABC
  Client->>Handler: POST /balances + Idempotency-Key: ABC
  Handler->>Manager: CreateBalance(ctx, "ABC", data)
  Manager->>Manager: Hash "ABC" → hash1
  Manager->>Manager: Look up account "my-balance"
  Manager->>Metadata: Account does not exist
  Manager->>Metadata: Create account + store hash1
  Manager->>Handler: Return new Balance
  Handler->>Client: 201 Created + Balance
  
  Note over Client,Metadata: Request 2: Same operation with same key ABC
  Client->>Handler: POST /balances + Idempotency-Key: ABC
  Handler->>Manager: CreateBalance(ctx, "ABC", data)
  Manager->>Manager: Hash "ABC" → hash1
  Manager->>Manager: Look up account "my-balance"
  Manager->>Metadata: Account exists + stored hash == hash1
  Manager->>Handler: Return existing Balance
  Handler->>Client: 201 Created + same Balance (replayed)
  
  Note over Client,Metadata: Request 3: Same operation with different key XYZ
  Client->>Handler: POST /balances + Idempotency-Key: XYZ
  Handler->>Manager: CreateBalance(ctx, "XYZ", data)
  Manager->>Manager: Hash "XYZ" → hash2
  Manager->>Manager: Look up account "my-balance"
  Manager->>Metadata: Account exists + stored hash (hash1) != hash2
  Manager->>Handler: Return error
  Handler->>Client: 400 Bad Request
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 With idempotent keys and hashes so fine,
Balance creation is now safe to retry,
No duplicate accounts will arise,
When the same request knocks—we recognize!
A SHA-256 dance, repeat-proof and sly. 🔑

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: forwarding the idempotency key on balance creation and documenting the race condition, which is the primary focus of the changeset.
Description check ✅ Passed The description is directly related to the changeset, clearly explaining the problem (empty idempotency key causing deduplication failures and undocumented race condition), the fix applied, and the testing approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/balance-create-idempotency

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@flemzord flemzord changed the title fix: forward idempotency key on balance creation and document create race fix: forward idempotency key on balance creation and document create race (EN-1205) Jun 11, 2026
Dav-14
Dav-14 previously approved these changes Jun 11, 2026

@flemzord flemzord left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Revue inline: le forwarding de l’Idempotency-Key ne suffit pas encore à rendre CreateBalance idempotent après un premier succès.

Comment thread pkg/manager.go
Address review feedback: after a successful create, a retry with the same
Idempotency-Key hit the existence check and returned ErrBalanceAlreadyExists
(400) rather than replaying the original 201. When an Idempotency-Key is
present, treat an already-existing balance as an idempotent replay and return
it; without a key, keep the explicit ALREADY_EXISTS conflict.

Adds TestBalancesCreateIdempotentReplay (two same-key/same-body calls both
return 201 and the metadata write happens only once).
Comment thread pkg/manager.go Outdated
@NumaryBot

Copy link
Copy Markdown
Contributor

💬 Comments — multi-model review

The PR correctly threads the HTTP Idempotency-Key through to AddMetadataToAccount, introduces an app-level replay short-circuit on retries, and documents the residual concurrent-create race. The core intent is sound and the primary changes are well-structured. However, several issues remain. Most importantly, the newly added idempotency hash stored in metadata is susceptible to the same last-write-wins race the PR documents for priority/expiresAt: a concurrent first-time create with a different key will overwrite the hash, silently breaking replay for the earlier caller without any documentation of this behavior. The relationship between the app-level hash check and ledger-level deduplication is also undocumented, making future maintenance error-prone. On the testing side, the replay test only validates the app-level short-circuit path and does not cover the ledger-dedup path that is the PR's primary fix, and replay correctness for Priority/ExpiresAt fields is not asserted.

🟡 [minor] Concurrent creates can clobber the idempotency replay marker

pkg/manager.go:595 — reported by gpt

The new idempotency hash is stored in a single metadata field. In the documented concurrent-create race where two first-time creates of the same balance pass the existence check with different idempotency keys, the later metadata write overwrites not only priority/expiresAt but also this idempotency hash. If the earlier caller retries after a timeout, CreateBalance sees the balance exists but the stored hash no longer matches, so it falls through to ErrBalanceAlreadyExists instead of replaying success. The PR's comment only documents priority/expiresAt as last-write-wins, leaving the idempotency marker's susceptibility undocumented and weakening the retry guarantee in exactly the race being described.

Suggestion: Either document that the idempotency marker is also last-write-wins in the concurrent first-create race, or store replay markers in non-overlapping metadata keys (e.g. keyed by hash/prefix) so concurrent writes preserve replayability for both callers while priority/expiresAt remain last-write-wins.

🟡 [minor] App-level idempotency replay is best-effort on top of ledger dedup, but this is not documented

pkg/manager.go:578 — reported by claude

The short-circuit at the hash-match check requires that a prior create successfully persisted the idempotency hash into metadata. The ledger's own deduplication of AddMetadataToAccount operates independently and may have a different retention window or ordering guarantee. These two mechanisms can disagree: the ledger may dedup a write the app never recorded, or the stored hash may have been overwritten by a concurrent create. In either case a retry with the original key hits ErrBalanceAlreadyExists (400) instead of replaying, with no code comment to explain this is intentional and expected.

Suggestion: Add a brief comment near the hash-match check noting that this is a best-effort app-level replay complementing ledger-level idempotency, and that a key mismatch falls through to ErrBalanceAlreadyExists by design.

🟡 [minor] Replay path reconstructs balance from metadata rather than from request data — divergence undetected by tests

pkg/manager.go:579 — reported by claude

On replay, CreateBalance returns BalanceFromAccount(*ret), reconstructing the balance from existing account metadata. The create path returns the freshly-built balance struct. If the stored metadata encodes priority or expiresAt differently from what BalanceFromAccount expects, the replayed response can diverge from the original. The test only asserts Name equality, so any divergence in Priority or ExpiresAt between the original and replayed responses would go undetected.

Suggestion: Add test assertions comparing Priority and ExpiresAt between the first create response and the replayed (idempotent) response, to lock in that replay returns an equivalent balance.

⚪ [nit] Idempotent-replay test does not exercise the ledger-dedup path

pkg/api/handler_balances_create_test.go:86 — reported by claude

TestBalancesCreateIdempotentReplay asserts addCalls==1, proving the second create did not re-write, but it relies entirely on the app-level GetAccount short-circuit (the existing account has a matching hash). It does not test the scenario where GetAccount keeps returning not-found and the ledger itself deduplicates the AddMetadataToAccount call. Since the PR's primary fix is forwarding the idempotency key to the ledger, this path is not covered.

Suggestion: Consider adding a test variant where GetAccount always returns not-found and the AddMetadataToAccount mock simulates ledger dedup (no-op on duplicate key), to document expected end-to-end behaviour and verify the forwarded key is actually used.

⚪ [nit] Verify no duplicate ptr/Ptr helper across package

pkg/api/handler_balances_create_test.go:17 — reported by claude

A ptr generic helper is defined at the top of the test file. If a wallet.Ptr or similar helper already exists elsewhere in the package, there is potential for reader confusion about which to use.

Suggestion: Confirm there is no redundant helper in the package and, if one exists, consolidate to a single shared utility.


Reviewed in parallel by claude (anthropic/claude-opus-4-8) and gpt (openai/gpt-5.5), then consolidated. This comment is updated on each push.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/manager.go (1)

577-593: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Concurrent first-create requests can invalidate each other's replay key.

The residual race is wider than the new comment suggests. If two first-time creates for the same balance arrive with different idempotency keys, both can pass Line 573, and the later write at Line 592 overwrites the earlier caller's stored hash. After that, a retry of the earlier successful request no longer matches Line 579 and returns ErrBalanceAlreadyExists instead of replaying the original 201.

Please either make that limitation explicit and cover it with a regression test, or move replay identity out of this single last-write-wins metadata slot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/manager.go` around lines 577 - 593, Concurrent creates can race when
assigning MetadataKeyBalanceIdempotencyKey: change the write to store the hashed
idempotency key only if the metadata slot is currently empty (atomic
set-if-not-exists) instead of always overwriting
balanceMetadata[MetadataKeyBalanceIdempotencyKey]; implement this using the
storage layer's conditional write/transaction or a compare-and-set operation in
the Create/ensure-balance path (where NewBalance, balanceMetadata and
hashIdempotencyKey are used) so the first writer wins deterministically, and add
a regression test that fires two concurrent first-time creates with different ik
values to assert the original creator's hash is preserved and retries replay
correctly (use BalanceFromAccount/MetadataKeyWalletBalance to verify behavior).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@pkg/manager.go`:
- Around line 577-593: Concurrent creates can race when assigning
MetadataKeyBalanceIdempotencyKey: change the write to store the hashed
idempotency key only if the metadata slot is currently empty (atomic
set-if-not-exists) instead of always overwriting
balanceMetadata[MetadataKeyBalanceIdempotencyKey]; implement this using the
storage layer's conditional write/transaction or a compare-and-set operation in
the Create/ensure-balance path (where NewBalance, balanceMetadata and
hashIdempotencyKey are used) so the first writer wins deterministically, and add
a regression test that fires two concurrent first-time creates with different ik
values to assert the original creator's hash is preserved and retries replay
correctly (use BalanceFromAccount/MetadataKeyWalletBalance to verify behavior).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3fd763bd-cac2-4154-9164-4b59f3c47271

📥 Commits

Reviewing files that changed from the base of the PR and between 85e1d3f and 1ddfa22.

📒 Files selected for processing (4)
  • pkg/api/handler_balances_create.go
  • pkg/api/handler_balances_create_test.go
  • pkg/manager.go
  • pkg/metadata.go

@flemzord flemzord enabled auto-merge (squash) June 16, 2026 08:56
@flemzord flemzord requested a review from gfyrag June 16, 2026 08:56
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.

4 participants