From 35d2a99a854e0d381588b8f1df48eea240a54d64 Mon Sep 17 00:00:00 2001 From: Simon Klinkert Date: Tue, 21 Jul 2026 21:20:52 +0000 Subject: [PATCH] Idempotency: honor Idempotency-Key header and apply it to deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write endpoints previously took the idempotency key only from the JSON body's optional idempotency_key field, and delete handlers never passed a key at all — so deletes had no idempotency protection. - Add an idempotencyKey helper that prefers the conventional Idempotency-Key HTTP header and falls back to the body field for backward compatibility (header wins when both are present). - Resolve the key via the helper in all six mutating handlers, and plumb it into DeleteProductCommand/DeleteSellerCommand (fields already existed but were never populated). - Document the header in the OpenAPI spec and README. Fully backward compatible: existing body-key and no-key callers are unchanged. --- README.md | 2 +- api/openapi.yaml | 25 +++++++++++++- internal/interface/api/rest/idempotency.go | 18 ++++++++++ .../interface/api/rest/idempotency_test.go | 34 +++++++++++++++++++ .../interface/api/rest/product_controller.go | 7 +++- .../interface/api/rest/seller_controller.go | 7 +++- 6 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 internal/interface/api/rest/idempotency.go create mode 100644 internal/interface/api/rest/idempotency_test.go diff --git a/README.md b/README.md index ccece80..df4b797 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ This separation enables different optimization strategies: ### Idempotency Keys Idempotency ensures that multiple identical requests have the same effect as a single request. This is crucial for handling network failures and retries in distributed systems. Implementation: -- Each command accepts an optional `idempotency_key` in the request +- Every mutating endpoint (create, update, **and delete**) accepts an optional key. The conventional `Idempotency-Key` HTTP header is preferred; an `idempotency_key` field in the JSON body is still honored as a fallback, and the header wins when both are sent - The key is **reserved atomically** (`INSERT ... ON CONFLICT DO NOTHING`), so two concurrent requests with the same key can never both execute — no check-then-write race - A completed request returns its cached response; a still-running one returns an "in progress" error so the client retries later - Reusing a key with a **different payload** is rejected instead of silently returning the wrong cached response diff --git a/api/openapi.yaml b/api/openapi.yaml index 85fbaca..20a6277 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -3,7 +3,11 @@ info: title: go-ddd Marketplace API description: | Example REST API of the go-ddd template. Sellers manage products. - Write endpoints accept an optional `idempotency_key` making retries safe. + Write endpoints (create, update, delete) are idempotent: send an + `Idempotency-Key` header to make retries safe. A completed request with the + same key replays its cached response; the same key with a different payload + is rejected. The legacy `idempotency_key` body field is still honored as a + fallback. version: "1.0.0" license: name: MIT @@ -43,6 +47,8 @@ paths: post: summary: Create a seller operationId: createSeller + parameters: + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: @@ -71,6 +77,8 @@ paths: put: summary: Update a seller operationId: updateSeller + parameters: + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: @@ -106,6 +114,7 @@ paths: operationId: deleteSeller parameters: - $ref: "#/components/parameters/Id" + - $ref: "#/components/parameters/IdempotencyKey" responses: "204": description: Seller deleted @@ -115,6 +124,8 @@ paths: post: summary: Create a product operationId: createProduct + parameters: + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: @@ -160,6 +171,7 @@ paths: operationId: updateProduct parameters: - $ref: "#/components/parameters/Id" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: @@ -180,6 +192,7 @@ paths: operationId: deleteProduct parameters: - $ref: "#/components/parameters/Id" + - $ref: "#/components/parameters/IdempotencyKey" responses: "204": description: Product deleted @@ -194,6 +207,16 @@ components: schema: type: string format: uuid + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: >- + Unique key that makes this request safe to retry. Repeating the request + with the same key replays the original response instead of executing + again. + schema: + type: string responses: BadRequest: description: Malformed request diff --git a/internal/interface/api/rest/idempotency.go b/internal/interface/api/rest/idempotency.go new file mode 100644 index 0000000..4e8e76c --- /dev/null +++ b/internal/interface/api/rest/idempotency.go @@ -0,0 +1,18 @@ +package rest + +import "github.com/labstack/echo/v4" + +// idempotencyHeader is the conventional header clients use to make a mutating +// request safe to retry. See https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ +const idempotencyHeader = "Idempotency-Key" + +// idempotencyKey resolves the idempotency key for a request. The Idempotency-Key +// header is the preferred transport; bodyKey (the request's optional +// "idempotency_key" field) is a backward-compatible fallback for callers that +// send the key in the JSON body. The header wins when both are present. +func idempotencyKey(c echo.Context, bodyKey string) string { + if header := c.Request().Header.Get(idempotencyHeader); header != "" { + return header + } + return bodyKey +} diff --git a/internal/interface/api/rest/idempotency_test.go b/internal/interface/api/rest/idempotency_test.go new file mode 100644 index 0000000..74afffc --- /dev/null +++ b/internal/interface/api/rest/idempotency_test.go @@ -0,0 +1,34 @@ +package rest + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func newContext(t *testing.T, header string) echo.Context { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/", nil) + if header != "" { + req.Header.Set(idempotencyHeader, header) + } + return echo.New().NewContext(req, httptest.NewRecorder()) +} + +func TestIdempotencyKey_HeaderTakesPrecedence(t *testing.T) { + c := newContext(t, "from-header") + assert.Equal(t, "from-header", idempotencyKey(c, "from-body")) +} + +func TestIdempotencyKey_FallsBackToBody(t *testing.T) { + c := newContext(t, "") + assert.Equal(t, "from-body", idempotencyKey(c, "from-body")) +} + +func TestIdempotencyKey_EmptyWhenNeitherPresent(t *testing.T) { + c := newContext(t, "") + assert.Empty(t, idempotencyKey(c, "")) +} diff --git a/internal/interface/api/rest/product_controller.go b/internal/interface/api/rest/product_controller.go index aa1ac83..83c2641 100644 --- a/internal/interface/api/rest/product_controller.go +++ b/internal/interface/api/rest/product_controller.go @@ -45,6 +45,7 @@ func (pc *ProductController) CreateProductController(c echo.Context) error { "error": "Invalid product Id format", }) } + productCommand.IdempotencyKey = idempotencyKey(c, productCommand.IdempotencyKey) result, err := pc.service.CreateProduct(c.Request().Context(), productCommand) if err != nil { @@ -116,6 +117,7 @@ func (pc *ProductController) UpdateProductController(c echo.Context) error { "error": "Invalid seller Id format", }) } + productCommand.IdempotencyKey = idempotencyKey(c, productCommand.IdempotencyKey) result, err := pc.service.UpdateProduct(c.Request().Context(), productCommand) if err != nil { @@ -135,7 +137,10 @@ func (pc *ProductController) DeleteProductController(c echo.Context) error { }) } - _, err = pc.service.DeleteProduct(c.Request().Context(), &command.DeleteProductCommand{Id: id}) + _, err = pc.service.DeleteProduct(c.Request().Context(), &command.DeleteProductCommand{ + IdempotencyKey: idempotencyKey(c, ""), + Id: id, + }) if err != nil { return writeCommandError(c, err, "Failed to delete product") } diff --git a/internal/interface/api/rest/seller_controller.go b/internal/interface/api/rest/seller_controller.go index d913a17..9d8d1ac 100644 --- a/internal/interface/api/rest/seller_controller.go +++ b/internal/interface/api/rest/seller_controller.go @@ -45,6 +45,7 @@ func (sc *SellerController) CreateSellerController(c echo.Context) error { "error": "Invalid seller Id format", }) } + sellerCommand.IdempotencyKey = idempotencyKey(c, sellerCommand.IdempotencyKey) commandResult, err := sc.service.CreateSeller(c.Request().Context(), sellerCommand) if err != nil { @@ -110,6 +111,7 @@ func (sc *SellerController) PutSellerController(c echo.Context) error { "error": "Invalid seller Id format", }) } + updateSellerCommand.IdempotencyKey = idempotencyKey(c, updateSellerCommand.IdempotencyKey) commandResult, err := sc.service.UpdateSeller(c.Request().Context(), updateSellerCommand) if err != nil { @@ -129,7 +131,10 @@ func (sc *SellerController) DeleteSellerController(c echo.Context) error { }) } - _, err = sc.service.DeleteSeller(c.Request().Context(), &command.DeleteSellerCommand{Id: id}) + _, err = sc.service.DeleteSeller(c.Request().Context(), &command.DeleteSellerCommand{ + IdempotencyKey: idempotencyKey(c, ""), + Id: id, + }) if err != nil { return writeCommandError(c, err, "Failed to delete seller") }