From 233f6121d8cb013624fb97f161552445e588d531 Mon Sep 17 00:00:00 2001 From: Morgan Roderick <20321+mroderick@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:38:16 +0100 Subject: [PATCH] docs(spec): classify scenarios as draft requirements or library policy Every scenario now sits in a Rule tagged @draft, with the draft section it implements, or @library for behaviour the draft leaves open. Cucumber inherits tags, so a scenario cannot hold a class of its own: the suite is grouped into Rules of one class each rather than tagged per scenario. Thirteen of the 49 scenarios trace to draft-07. The rest cover policy the page used to present under draft section headings, such as the 21 and 255 character limits, the comma rejection, the 409 for a same-body different-key request, and all response recording and replay. --- docs/specs/idempotency.feature | 800 ++++++++++++++++--------------- docs/specs/index.md | 826 +++++++++++++++++---------------- 2 files changed, 858 insertions(+), 768 deletions(-) diff --git a/docs/specs/idempotency.feature b/docs/specs/idempotency.feature index e725c7d..97431c0 100644 --- a/docs/specs/idempotency.feature +++ b/docs/specs/idempotency.feature @@ -1,3 +1,8 @@ +# Every scenario belongs to exactly one Rule. A Rule tagged @draft states a +# requirement of draft-ietf-httpapi-idempotency-key-header-07 and carries the +# draft section in a @section tag. A Rule tagged @library states behaviour this +# library chose, which the draft does not specify. Tags are inherited, so a +# scenario must never carry a class tag of its own. Feature: Idempotency-Key Header Compliance As a developer implementing the IETF Idempotency-Key Header specification I want to verify my middleware complies with the spec @@ -7,382 +12,419 @@ Feature: Idempotency-Key Header Compliance Given a POST endpoint at "/api" that creates an order And the SQLite store is clean - # Header Validation (Section 2.1 - Syntax) - - Scenario: Missing Idempotency-Key header on POST returns 400 - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - - Scenario: Missing key error includes RFC 9457 instance field - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Missing key error includes RFC 9457 retryable field - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Empty Idempotency-Key header returns 400 - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Empty key error includes RFC 9457 instance field - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Empty key error includes RFC 9457 retryable field - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Empty key error includes idempotency_key extension - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "idempotency_key" - - Scenario: Idempotency-Key exceeding 255 characters returns 400 - Given an Idempotency-Key of 256 characters - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Too long key error includes RFC 9457 instance field - Given an Idempotency-Key of 256 characters - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Too long key error includes RFC 9457 retryable field - Given an Idempotency-Key of 256 characters - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Idempotency-Key with commas returns 400 - Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Invalid key with commas includes RFC 9457 instance field - Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Invalid key with commas includes RFC 9457 retryable field - Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Valid Idempotency-Key is accepted - Given an Idempotency-Key "8e03978e-40d5-43e8-bc93-6894a57f9324" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - - # First Request Handling (Section 2.6 - Idempotency Enforcement) - - Scenario: First request creates idempotency record - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then an idempotency record should exist with key "abc123456789012345678" - And the idempotency record status should be "complete" - - Scenario: First request stores response status - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the idempotency record response status should be 200 - - Scenario: First request stores response body - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the idempotency record response body should contain "success" - - Scenario: First request creates expected resource - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then 1 orders should exist in the database - - Scenario: First request returns correct response body - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And the response body should contain "success" - - # Duplicate Request Handling (Section 2.6 - Retry) - - Scenario: Duplicate request returns cached response - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And the response body should contain "success" - - Scenario: Duplicate request sets replay header - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response should have header "x-idempotent-replayed" with value "true" - - Scenario: Duplicate request does not create additional idempotency records - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then 1 idempotency records should exist with key "abc123456789012345678" - - Scenario: Duplicate request does not duplicate resource creation - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then 1 orders should exist in the database - - Scenario: Multiple duplicate requests return cached response - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - And I send a POST request to "/api" with body '{"foo":"bar"}' - And I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And 1 orders should exist in the database - - # Fingerprint Conflict (Section 2.2 - Uniqueness) - - Scenario: Different key with same body returns 409 - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - - Scenario: Fingerprint conflict response contains error message - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - And the response body should contain "title" - - Scenario: Fingerprint conflict does not create duplicate resources - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - And 1 orders should exist in the database - - Scenario: Fingerprint conflict includes RFC 9457 instance field - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Fingerprint conflict includes RFC 9457 retryable field - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "retryable" - And the response retryable field should be false - - # Key Reuse Conflict (Section 2.2 - Uniqueness) - - Scenario: Same key with different body returns 422 - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - - Scenario: Key reuse conflict response contains error message - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - And the response body should contain "title" - - Scenario: Key reuse conflict includes RFC 9457 instance field - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Key reuse conflict includes RFC 9457 retryable field - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Key reuse conflict includes idempotency_key extension - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response body should contain "idempotency_key" - And the response idempotency_key field should be "abc123456789012345678" - - # Concurrent Request Handling (Section 2.6 - Concurrent Request) - - Scenario: Request during processing returns 409 - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.6" - - Scenario: Concurrent request response contains error message - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.6" - And the response body should contain "processed" - - Scenario: Concurrent request includes RFC 9457 instance field - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Concurrent request includes RFC 9457 retryable field - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "retryable" - And the response retryable field should be true - - # Error Response Format (Section 2.7 - Error Handling) - RFC 9457 - - Scenario: Missing key error follows RFC 9457 - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Key reuse error follows RFC 9457 - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response content-type should be "application/problem+json" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Fingerprint conflict error follows RFC 9457 - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - # Infrastructure Errors (Store Unavailable) - - Scenario: Store unavailable returns 503 - Given the idempotency store is unavailable - And an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 503 - And the response content-type should be "application/problem+json" - - Scenario: Store unavailable includes RFC 9457 instance field - Given the idempotency store is unavailable - And an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 503 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Store unavailable includes RFC 9457 retryable field - Given the idempotency store is unavailable - And an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 503 - And the response body should contain "retryable" - And the response retryable field should be true - - # Edge Cases - - Scenario: GET requests bypass idempotency processing - Given an Idempotency-Key "abc123456789012345678" - When I send a GET request to "/api" - Then the response status should be 200 - - Scenario: PUT requests bypass idempotency processing - Given an Idempotency-Key "abc123456789012345678" - When I send a PUT request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - - Scenario: DELETE requests bypass idempotency processing - Given an Idempotency-Key "abc123456789012345678" - When I send a DELETE request to "/api" - Then the response status should be 200 - - Scenario: Empty request body is handled - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with empty body - Then the response status should be 200 - - Scenario: PATCH requests are protected by idempotency - Given a PATCH endpoint at "/api" that creates an order - And an Idempotency-Key "abc123456789012345678" - When I send a PATCH request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And an idempotency record should exist with key "abc123456789012345678" \ No newline at end of file + @draft @section:2.1 + Rule: Draft section 2.1 - The key value is a String + The draft requires the Idempotency-Key field to be an Item Structured Header + whose value is a String. Accepting a well-formed key is the observable half of + that requirement. + + Scenario: Valid Idempotency-Key is accepted + Given an Idempotency-Key "8e03978e-40d5-43e8-bc93-6894a57f9324" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + + @draft @section:2.6 + Rule: Draft section 2.6 - Idempotency enforcement + The draft requires the resource to process a request it has not seen and to + respond with the result of the previously completed operation when the same + key and fingerprint arrive again. + + Scenario: First request creates expected resource + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then 1 orders should exist in the database + + Scenario: First request returns correct response body + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And the response body should contain "success" + + Scenario: Duplicate request returns cached response + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And the response body should contain "success" + + Scenario: Duplicate request does not duplicate resource creation + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then 1 orders should exist in the database + + Scenario: Multiple duplicate requests return cached response + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + And I send a POST request to "/api" with body '{"foo":"bar"}' + And I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And 1 orders should exist in the database + + @draft @section:2.7 + Rule: Draft section 2.7 - Error handling + The draft requires a 400 when the key is missing, a 422 when a key is reused + with a different payload, and a 409 when a retry arrives before the original + completes. Its examples show each of those carried in an RFC 9457 problem + document with a type, a title and a detail. + + Scenario: Missing Idempotency-Key header on POST returns 400 + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + + Scenario: Same key with different body returns 422 + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + + Scenario: Key reuse conflict response contains error message + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + And the response body should contain "title" + + Scenario: Request during processing returns 409 + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.6" + + Scenario: Concurrent request response contains error message + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.6" + And the response body should contain "processed" + + Scenario: Missing key error follows RFC 9457 + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Key reuse error follows RFC 9457 + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response content-type should be "application/problem+json" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + @library + Rule: Library policy - Key validation and length limits + The draft fixes the syntax of the value but not its length, and it permits any + character an sf-string permits, commas included. The 21-character minimum, the + 255-character maximum, the comma rejection and the empty-key rejection are this + library's policy, as are the RFC 9457 extension members asserted below. + + Scenario: Missing key error includes RFC 9457 instance field + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Missing key error includes RFC 9457 retryable field + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Empty Idempotency-Key header returns 400 + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Empty key error includes RFC 9457 instance field + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Empty key error includes RFC 9457 retryable field + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Empty key error includes idempotency_key extension + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "idempotency_key" + + Scenario: Idempotency-Key exceeding 255 characters returns 400 + Given an Idempotency-Key of 256 characters + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Too long key error includes RFC 9457 instance field + Given an Idempotency-Key of 256 characters + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Too long key error includes RFC 9457 retryable field + Given an Idempotency-Key of 256 characters + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Idempotency-Key with commas returns 400 + Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Invalid key with commas includes RFC 9457 instance field + Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Invalid key with commas includes RFC 9457 retryable field + Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + @library + Rule: Library policy - What the library records and replays + The draft asks for the previous result to be returned but does not say how it + is kept. The record itself, its status field, the replayed status and body, and + the x-idempotent-replayed marker are this library's mechanism. + + Scenario: First request creates idempotency record + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then an idempotency record should exist with key "abc123456789012345678" + And the idempotency record status should be "complete" + + Scenario: First request stores response status + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the idempotency record response status should be 200 + + Scenario: First request stores response body + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the idempotency record response body should contain "success" + + Scenario: Duplicate request sets replay header + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response should have header "x-idempotent-replayed" with value "true" + + Scenario: Duplicate request does not create additional idempotency records + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then 1 idempotency records should exist with key "abc123456789012345678" + + @library + Rule: Library policy - Fingerprint conflict detection + The draft leaves uniqueness to the resource owner and never maps a request that + reuses a payload under a different key to a status. Answering it with 409 and a + section-2.2 problem type is this library's choice. + + Scenario: Different key with same body returns 409 + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + + Scenario: Fingerprint conflict response contains error message + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + And the response body should contain "title" + + Scenario: Fingerprint conflict does not create duplicate resources + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + And 1 orders should exist in the database + + Scenario: Fingerprint conflict includes RFC 9457 instance field + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Fingerprint conflict includes RFC 9457 retryable field + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Fingerprint conflict error follows RFC 9457 + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + @library + Rule: Library policy - Problem document extension members + RFC 9457 permits extension members. Adding instance, retryable and + idempotency_key to the draft's error bodies is this library's choice, as is + asserting them for the statuses the draft does define. + + Scenario: Key reuse conflict includes RFC 9457 instance field + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Key reuse conflict includes RFC 9457 retryable field + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Key reuse conflict includes idempotency_key extension + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response body should contain "idempotency_key" + And the response idempotency_key field should be "abc123456789012345678" + + Scenario: Concurrent request includes RFC 9457 instance field + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Concurrent request includes RFC 9457 retryable field + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "retryable" + And the response retryable field should be true + + @library + Rule: Library policy - Store failures + The draft does not cover an unavailable store. Answering 503 with a problem + document, and marking it retryable, is this library's choice. + + Scenario: Store unavailable returns 503 + Given the idempotency store is unavailable + And an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 503 + And the response content-type should be "application/problem+json" + + Scenario: Store unavailable includes RFC 9457 instance field + Given the idempotency store is unavailable + And an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 503 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Store unavailable includes RFC 9457 retryable field + Given the idempotency store is unavailable + And an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 503 + And the response body should contain "retryable" + And the response retryable field should be true + + @library + Rule: Library policy - Method scope and edge cases + The draft never states which methods a resource should protect. That POST and + PATCH are processed, and that the already-idempotent methods are not, follows + from RFC 9110's method list rather than from the draft. + + Scenario: GET requests bypass idempotency processing + Given an Idempotency-Key "abc123456789012345678" + When I send a GET request to "/api" + Then the response status should be 200 + + Scenario: PUT requests bypass idempotency processing + Given an Idempotency-Key "abc123456789012345678" + When I send a PUT request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + + Scenario: DELETE requests bypass idempotency processing + Given an Idempotency-Key "abc123456789012345678" + When I send a DELETE request to "/api" + Then the response status should be 200 + + Scenario: Empty request body is handled + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with empty body + Then the response status should be 200 + + Scenario: PATCH requests are protected by idempotency + Given a PATCH endpoint at "/api" that creates an order + And an Idempotency-Key "abc123456789012345678" + When I send a PATCH request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And an idempotency record should exist with key "abc123456789012345678" diff --git a/docs/specs/index.md b/docs/specs/index.md index a545d37..251e4be 100644 --- a/docs/specs/index.md +++ b/docs/specs/index.md @@ -7,20 +7,26 @@ next: false # Cucumber Specifications -These Cucumber specifications codify idempot.dev's interpretation of [draft-ietf-httpapi-idempotency-key-header-07](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07). The IETF draft defines the requirements; these specs translate them into implementation choices shared by all idempot.dev projects. +These Cucumber specifications separate what [draft-ietf-httpapi-idempotency-key-header-07](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07) requires from the behaviour this project chose. Every scenario sits in a `Rule` tagged either `@draft`, which names the draft section it implements, or `@library`, which marks behaviour the draft leaves open. All idempot.dev projects share them. ## Overview -These specifications define the expected behavior for idempotency middleware implementations. They cover: +13 of the 49 scenarios trace to the draft. The other 36 cover policy this project chose, and the `Rule` a scenario sits in says which it is. -- **Header Validation** (Section 2.1) — Syntax and key format requirements -- **First Request Handling** (Section 2.6) — Processing and storing new requests -- **Duplicate Request Handling** (Section 2.6 - Retry) — Returning cached responses -- **Fingerprint Conflict** (Section 2.2) — Detecting same body, different key -- **Key Reuse Conflict** (Section 2.2) — Detecting same key, different body -- **Concurrent Request Handling** (Section 2.6) — Handling in-flight requests -- **Error Response Format** (Section 2.7) — RFC 9457 Problem Details format -- **Infrastructure Errors** (Store Unavailable) — 503 responses when the store is down +Draft requirements: + +- **Section 2.1** — the key value is a String (1 scenario) +- **Section 2.6** — a first request is processed, a duplicate replays the result, no resource is created twice (5 scenarios) +- **Section 2.7** — 400, 422 and 409 with problem documents carrying type, title and detail (7 scenarios) + +Library policy: + +- **Key validation and length limits** — the 21 and 255 character limits, comma rejection, empty keys (12 scenarios) +- **What the library records and replays** — the stored record, the replayed status and body, the replay marker (5 scenarios) +- **Fingerprint conflict detection** — the same body under a different key answered with 409 (6 scenarios) +- **Problem document extension members** — instance, retryable and idempotency_key (5 scenarios) +- **Store failures** — 503 with a retryable problem document (3 scenarios) +- **Method scope and edge cases** — POST and PATCH protected, already-idempotent methods bypassed (5 scenarios) ## Download @@ -29,6 +35,11 @@ These specifications define the expected behavior for idempotency middleware imp ## Specification ```gherkin +# Every scenario belongs to exactly one Rule. A Rule tagged @draft states a +# requirement of draft-ietf-httpapi-idempotency-key-header-07 and carries the +# draft section in a @section tag. A Rule tagged @library states behaviour this +# library chose, which the draft does not specify. Tags are inherited, so a +# scenario must never carry a class tag of its own. Feature: Idempotency-Key Header Compliance As a developer implementing the IETF Idempotency-Key Header specification I want to verify my middleware complies with the spec @@ -38,383 +49,420 @@ Feature: Idempotency-Key Header Compliance Given a POST endpoint at "/api" that creates an order And the SQLite store is clean - # Header Validation (Section 2.1 - Syntax) - - Scenario: Missing Idempotency-Key header on POST returns 400 - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - - Scenario: Missing key error includes RFC 9457 instance field - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Missing key error includes RFC 9457 retryable field - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Empty Idempotency-Key header returns 400 - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Empty key error includes RFC 9457 instance field - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Empty key error includes RFC 9457 retryable field - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Empty key error includes idempotency_key extension - Given an empty Idempotency-Key header - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "idempotency_key" - - Scenario: Idempotency-Key exceeding 255 characters returns 400 - Given an Idempotency-Key of 256 characters - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Too long key error includes RFC 9457 instance field - Given an Idempotency-Key of 256 characters - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Too long key error includes RFC 9457 retryable field - Given an Idempotency-Key of 256 characters - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Idempotency-Key with commas returns 400 - Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.1" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Invalid key with commas includes RFC 9457 instance field - Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Invalid key with commas includes RFC 9457 retryable field - Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 400 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Valid Idempotency-Key is accepted - Given an Idempotency-Key "8e03978e-40d5-43e8-bc93-6894a57f9324" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - - # First Request Handling (Section 2.6 - Idempotency Enforcement) - - Scenario: First request creates idempotency record - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then an idempotency record should exist with key "abc123456789012345678" - And the idempotency record status should be "complete" - - Scenario: First request stores response status - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the idempotency record response status should be 200 - - Scenario: First request stores response body - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the idempotency record response body should contain "success" - - Scenario: First request creates expected resource - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then 1 orders should exist in the database - - Scenario: First request returns correct response body - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And the response body should contain "success" - - # Duplicate Request Handling (Section 2.6 - Retry) - - Scenario: Duplicate request returns cached response - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And the response body should contain "success" - - Scenario: Duplicate request sets replay header - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response should have header "x-idempotent-replayed" with value "true" - - Scenario: Duplicate request does not create additional idempotency records - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then 1 idempotency records should exist with key "abc123456789012345678" - - Scenario: Duplicate request does not duplicate resource creation - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then 1 orders should exist in the database - - Scenario: Multiple duplicate requests return cached response - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"foo":"bar"}' - And I send a POST request to "/api" with body '{"foo":"bar"}' - And I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And 1 orders should exist in the database - - # Fingerprint Conflict (Section 2.2 - Uniqueness) - - Scenario: Different key with same body returns 409 - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - - Scenario: Fingerprint conflict response contains error message - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - And the response body should contain "title" - - Scenario: Fingerprint conflict does not create duplicate resources - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - And 1 orders should exist in the database - - Scenario: Fingerprint conflict includes RFC 9457 instance field - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Fingerprint conflict includes RFC 9457 retryable field - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "retryable" - And the response retryable field should be false - - # Key Reuse Conflict (Section 2.2 - Uniqueness) - - Scenario: Same key with different body returns 422 - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - - Scenario: Key reuse conflict response contains error message - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.2" - And the response body should contain "title" - - Scenario: Key reuse conflict includes RFC 9457 instance field - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Key reuse conflict includes RFC 9457 retryable field - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response body should contain "retryable" - And the response retryable field should be false - - Scenario: Key reuse conflict includes idempotency_key extension - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response body should contain "idempotency_key" - And the response idempotency_key field should be "abc123456789012345678" - - # Concurrent Request Handling (Section 2.6 - Concurrent Request) - - Scenario: Request during processing returns 409 - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.6" - - Scenario: Concurrent request response contains error message - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "section-2.6" - And the response body should contain "processed" - - Scenario: Concurrent request includes RFC 9457 instance field - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Concurrent request includes RFC 9457 retryable field - Given an Idempotency-Key "abc123456789012345678" - And the key "abc123456789012345678" is currently being processed - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response body should contain "retryable" - And the response retryable field should be true - - # Error Response Format (Section 2.7 - Error Handling) - RFC 9457 - - Scenario: Missing key error follows RFC 9457 - When I send a POST request to "/api" without an Idempotency-Key header - Then the response status should be 400 - And the response content-type should be "application/problem+json" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Key reuse error follows RFC 9457 - Given an Idempotency-Key "abc123456789012345678" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - When I send a POST request to "/api" with body '{"baz":"qux"}' - Then the response status should be 422 - And the response content-type should be "application/problem+json" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - Scenario: Fingerprint conflict error follows RFC 9457 - Given an Idempotency-Key "key1-12345678901234567" - And I previously sent a POST request to "/api" with body '{"foo":"bar"}' - And an Idempotency-Key "key2-12345678901234567" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 409 - And the response content-type should be "application/problem+json" - And the response body should contain "type" - And the response body should contain "title" - And the response body should contain "detail" - - # Infrastructure Errors (Store Unavailable) - - Scenario: Store unavailable returns 503 - Given the idempotency store is unavailable - And an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 503 - And the response content-type should be "application/problem+json" - - Scenario: Store unavailable includes RFC 9457 instance field - Given the idempotency store is unavailable - And an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 503 - And the response body should contain "instance" - And the response body instance field should match pattern "urn:uuid:" - - Scenario: Store unavailable includes RFC 9457 retryable field - Given the idempotency store is unavailable - And an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with body '{"foo":"bar"}' - Then the response status should be 503 - And the response body should contain "retryable" - And the response retryable field should be true - - # Edge Cases - - Scenario: GET requests bypass idempotency processing - Given an Idempotency-Key "abc123456789012345678" - When I send a GET request to "/api" - Then the response status should be 200 - - Scenario: PUT requests bypass idempotency processing - Given an Idempotency-Key "abc123456789012345678" - When I send a PUT request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - - Scenario: DELETE requests bypass idempotency processing - Given an Idempotency-Key "abc123456789012345678" - When I send a DELETE request to "/api" - Then the response status should be 200 - - Scenario: Empty request body is handled - Given an Idempotency-Key "abc123456789012345678" - When I send a POST request to "/api" with empty body - Then the response status should be 200 - - Scenario: PATCH requests are protected by idempotency - Given a PATCH endpoint at "/api" that creates an order - And an Idempotency-Key "abc123456789012345678" - When I send a PATCH request to "/api" with body '{"foo":"bar"}' - Then the response status should be 200 - And an idempotency record should exist with key "abc123456789012345678" + @draft @section:2.1 + Rule: Draft section 2.1 - The key value is a String + The draft requires the Idempotency-Key field to be an Item Structured Header + whose value is a String. Accepting a well-formed key is the observable half of + that requirement. + + Scenario: Valid Idempotency-Key is accepted + Given an Idempotency-Key "8e03978e-40d5-43e8-bc93-6894a57f9324" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + + @draft @section:2.6 + Rule: Draft section 2.6 - Idempotency enforcement + The draft requires the resource to process a request it has not seen and to + respond with the result of the previously completed operation when the same + key and fingerprint arrive again. + + Scenario: First request creates expected resource + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then 1 orders should exist in the database + + Scenario: First request returns correct response body + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And the response body should contain "success" + + Scenario: Duplicate request returns cached response + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And the response body should contain "success" + + Scenario: Duplicate request does not duplicate resource creation + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then 1 orders should exist in the database + + Scenario: Multiple duplicate requests return cached response + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + And I send a POST request to "/api" with body '{"foo":"bar"}' + And I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And 1 orders should exist in the database + + @draft @section:2.7 + Rule: Draft section 2.7 - Error handling + The draft requires a 400 when the key is missing, a 422 when a key is reused + with a different payload, and a 409 when a retry arrives before the original + completes. Its examples show each of those carried in an RFC 9457 problem + document with a type, a title and a detail. + + Scenario: Missing Idempotency-Key header on POST returns 400 + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + + Scenario: Same key with different body returns 422 + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + + Scenario: Key reuse conflict response contains error message + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + And the response body should contain "title" + + Scenario: Request during processing returns 409 + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.6" + + Scenario: Concurrent request response contains error message + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.6" + And the response body should contain "processed" + + Scenario: Missing key error follows RFC 9457 + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Key reuse error follows RFC 9457 + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response content-type should be "application/problem+json" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + @library + Rule: Library policy - Key validation and length limits + The draft fixes the syntax of the value but not its length, and it permits any + character an sf-string permits, commas included. The 21-character minimum, the + 255-character maximum, the comma rejection and the empty-key rejection are this + library's policy, as are the RFC 9457 extension members asserted below. + + Scenario: Missing key error includes RFC 9457 instance field + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Missing key error includes RFC 9457 retryable field + When I send a POST request to "/api" without an Idempotency-Key header + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Empty Idempotency-Key header returns 400 + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Empty key error includes RFC 9457 instance field + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Empty key error includes RFC 9457 retryable field + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Empty key error includes idempotency_key extension + Given an empty Idempotency-Key header + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "idempotency_key" + + Scenario: Idempotency-Key exceeding 255 characters returns 400 + Given an Idempotency-Key of 256 characters + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Too long key error includes RFC 9457 instance field + Given an Idempotency-Key of 256 characters + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Too long key error includes RFC 9457 retryable field + Given an Idempotency-Key of 256 characters + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Idempotency-Key with commas returns 400 + Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.1" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + Scenario: Invalid key with commas includes RFC 9457 instance field + Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Invalid key with commas includes RFC 9457 retryable field + Given an Idempotency-Key "key,with,commas,longer-than-twenty-chars" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 400 + And the response body should contain "retryable" + And the response retryable field should be false + + @library + Rule: Library policy - What the library records and replays + The draft asks for the previous result to be returned but does not say how it + is kept. The record itself, its status field, the replayed status and body, and + the x-idempotent-replayed marker are this library's mechanism. + + Scenario: First request creates idempotency record + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then an idempotency record should exist with key "abc123456789012345678" + And the idempotency record status should be "complete" + + Scenario: First request stores response status + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the idempotency record response status should be 200 + + Scenario: First request stores response body + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the idempotency record response body should contain "success" + + Scenario: Duplicate request sets replay header + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response should have header "x-idempotent-replayed" with value "true" + + Scenario: Duplicate request does not create additional idempotency records + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then 1 idempotency records should exist with key "abc123456789012345678" + + @library + Rule: Library policy - Fingerprint conflict detection + The draft leaves uniqueness to the resource owner and never maps a request that + reuses a payload under a different key to a status. Answering it with 409 and a + section-2.2 problem type is this library's choice. + + Scenario: Different key with same body returns 409 + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + + Scenario: Fingerprint conflict response contains error message + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + And the response body should contain "title" + + Scenario: Fingerprint conflict does not create duplicate resources + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "section-2.2" + And 1 orders should exist in the database + + Scenario: Fingerprint conflict includes RFC 9457 instance field + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Fingerprint conflict includes RFC 9457 retryable field + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Fingerprint conflict error follows RFC 9457 + Given an Idempotency-Key "key1-12345678901234567" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + And an Idempotency-Key "key2-12345678901234567" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response content-type should be "application/problem+json" + And the response body should contain "type" + And the response body should contain "title" + And the response body should contain "detail" + + @library + Rule: Library policy - Problem document extension members + RFC 9457 permits extension members. Adding instance, retryable and + idempotency_key to the draft's error bodies is this library's choice, as is + asserting them for the statuses the draft does define. + + Scenario: Key reuse conflict includes RFC 9457 instance field + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Key reuse conflict includes RFC 9457 retryable field + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response body should contain "retryable" + And the response retryable field should be false + + Scenario: Key reuse conflict includes idempotency_key extension + Given an Idempotency-Key "abc123456789012345678" + And I previously sent a POST request to "/api" with body '{"foo":"bar"}' + When I send a POST request to "/api" with body '{"baz":"qux"}' + Then the response status should be 422 + And the response body should contain "idempotency_key" + And the response idempotency_key field should be "abc123456789012345678" + + Scenario: Concurrent request includes RFC 9457 instance field + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Concurrent request includes RFC 9457 retryable field + Given an Idempotency-Key "abc123456789012345678" + And the key "abc123456789012345678" is currently being processed + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 409 + And the response body should contain "retryable" + And the response retryable field should be true + + @library + Rule: Library policy - Store failures + The draft does not cover an unavailable store. Answering 503 with a problem + document, and marking it retryable, is this library's choice. + + Scenario: Store unavailable returns 503 + Given the idempotency store is unavailable + And an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 503 + And the response content-type should be "application/problem+json" + + Scenario: Store unavailable includes RFC 9457 instance field + Given the idempotency store is unavailable + And an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 503 + And the response body should contain "instance" + And the response body instance field should match pattern "urn:uuid:" + + Scenario: Store unavailable includes RFC 9457 retryable field + Given the idempotency store is unavailable + And an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with body '{"foo":"bar"}' + Then the response status should be 503 + And the response body should contain "retryable" + And the response retryable field should be true + + @library + Rule: Library policy - Method scope and edge cases + The draft never states which methods a resource should protect. That POST and + PATCH are processed, and that the already-idempotent methods are not, follows + from RFC 9110's method list rather than from the draft. + + Scenario: GET requests bypass idempotency processing + Given an Idempotency-Key "abc123456789012345678" + When I send a GET request to "/api" + Then the response status should be 200 + + Scenario: PUT requests bypass idempotency processing + Given an Idempotency-Key "abc123456789012345678" + When I send a PUT request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + + Scenario: DELETE requests bypass idempotency processing + Given an Idempotency-Key "abc123456789012345678" + When I send a DELETE request to "/api" + Then the response status should be 200 + + Scenario: Empty request body is handled + Given an Idempotency-Key "abc123456789012345678" + When I send a POST request to "/api" with empty body + Then the response status should be 200 + + Scenario: PATCH requests are protected by idempotency + Given a PATCH endpoint at "/api" that creates an order + And an Idempotency-Key "abc123456789012345678" + When I send a PATCH request to "/api" with body '{"foo":"bar"}' + Then the response status should be 200 + And an idempotency record should exist with key "abc123456789012345678" ```