-
-
Notifications
You must be signed in to change notification settings - Fork 176
feat(idempotency): add idempotency package #1997
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 11 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
99a95fa
wip
xHeaven 8c50e0d
wip
xHeaven a2e700e
wip
xHeaven a622973
wip
xHeaven aa690f9
wip
xHeaven 23e00c1
wip
xHeaven df72e17
wip
xHeaven 2caea11
wip
xHeaven 4264c99
refactor(idempotency): rename exception classes
xHeaven 99e7576
chore(idempotency): add simple explanation phpdocs for heartbeat renewer
xHeaven 600eaae
docs: add documentation for idempotency
xHeaven 8348180
refactor(idempotency): move interfaces to root namespace
xHeaven 2fe2ec3
refactor(idempotent): streamline Idempotent attribute
xHeaven 2a7e3eb
docs: update Idempotent attribute usage
xHeaven File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| --- | ||
| title: Idempotency | ||
| description: "Prevent duplicate side effects for HTTP routes and command bus commands by storing and replaying the result of the first execution." | ||
| --- | ||
|
|
||
| ## Overview | ||
|
|
||
| Payment processing, order creation, resource provisioning - any operation where retrying the same request should not produce duplicate side effects. Timeouts, client retries, and accidental double clicks all cause the same problem: the server cannot distinguish a retry from a new request. | ||
|
|
||
| The `tempest/idempotency` package solves this by storing the result of the first execution and replaying it for subsequent requests with the same idempotency key. It supports both [HTTP routes](#idempotent-routes) and [command bus commands](#idempotent-commands). | ||
|
|
||
| ## Idempotent routes | ||
|
|
||
| Add the {b`Tempest\Idempotency\Attributes\Idempotent`} attribute to a controller method. Clients send an `Idempotency-Key` header with a unique value (typically a UUID). The first request executes normally and caches the response. Subsequent requests with the same key replay the cached response without re-executing the handler. | ||
|
|
||
| ```php app/OrderController.php | ||
| use Tempest\Router\Post; | ||
| use Tempest\Http\Response; | ||
| use Tempest\Http\GenericResponse; | ||
| use Tempest\Http\Status; | ||
| use Tempest\Idempotency\Attributes\Idempotent; | ||
|
|
||
| final readonly class OrderController | ||
| { | ||
| #[Post('/orders')] | ||
| #[Idempotent] | ||
| public function create(CreateOrderRequest $request): Response | ||
| { | ||
| $order = $this->orderService->create($request); | ||
|
|
||
| return new GenericResponse( | ||
| status: Status::CREATED, | ||
| body: ['id' => $order->id], | ||
| ); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The client must include the idempotency key as a header: | ||
|
|
||
| ``` | ||
| POST /orders HTTP/1.1 | ||
| Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 | ||
| Content-Type: application/json | ||
|
|
||
| {"product": "widget", "quantity": 3} | ||
| ``` | ||
|
|
||
| When a cached response is replayed, the response includes an `idempotency-replayed: true` header so the client can distinguish replays from original executions. | ||
|
|
||
| ### Supported methods | ||
|
|
||
| Idempotency is only supported for `POST` and `PATCH` routes. Applying `#[Idempotent]` to a `GET`, `PUT`, `DELETE`, or other method will throw an {b`Tempest\Idempotency\Exceptions\IdempotencyMethodWasNotSupported`} exception. `GET` is inherently idempotent, `PUT` and `DELETE` are idempotent by definition in HTTP semantics, and only `POST` and `PATCH` produce non-idempotent side effects. | ||
|
|
||
| ### Scope resolver | ||
|
|
||
| Idempotency keys must be scoped per user or client to prevent key collisions across different actors. This is done by implementing the {b`Tempest\Idempotency\Contracts\IdempotencyScopeResolver`} interface and registering it in the container. | ||
|
|
||
| The `resolve()` method receives the current request and must return a string that uniquely identifies the caller - such as a user ID, session ID, or API key: | ||
|
|
||
| ```php app/UserIdempotencyScopeResolver.php | ||
| use Tempest\Http\Request; | ||
| use Tempest\Idempotency\Contracts\IdempotencyScopeResolver; | ||
|
|
||
| final readonly class UserIdempotencyScopeResolver implements IdempotencyScopeResolver | ||
| { | ||
| public function __construct( | ||
| private AuthManager $auth, | ||
| ) {} | ||
|
|
||
| public function resolve(Request $request): string | ||
| { | ||
| return (string) $this->auth->currentUser()->id; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| :::warning | ||
| A scope resolver is required. If no implementation of {b`Tempest\Idempotency\Contracts\IdempotencyScopeResolver`} is registered in the container, the middleware will fail at construction time. | ||
| ::: | ||
|
|
||
| ### Per-route overrides | ||
|
|
||
| The `#[Idempotent]` attribute accepts optional parameters to override the global configuration on a per-route basis: | ||
|
|
||
| ```php app/PaymentController.php | ||
| use Tempest\Router\Post; | ||
| use Tempest\Http\Response; | ||
| use Tempest\Idempotency\Attributes\Idempotent; | ||
|
|
||
| final readonly class PaymentController | ||
| { | ||
| #[Post('/payments')] | ||
| #[Idempotent(ttlInSeconds: 172_800, requireKey: true)] | ||
| public function charge(ChargeRequest $request): Response | ||
| { | ||
| // Cached response persists for 48 hours instead of the default 24 | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| | Parameter | Type | Description | | ||
| |---|---|---| | ||
| | `ttlInSeconds` | `?int` | How long a completed response is cached. Defaults to the config value (86400 / 24 hours). | | ||
| | `pendingTtlInSeconds` | `?int` | How long a pending (in-progress) record is considered active. Defaults to the config value (60 seconds). | | ||
| | `requireKey` | `?bool` | Whether requests without the idempotency key header should be rejected with a 400 response. Defaults to `true`. | | ||
| | `header` | `?string` | The header name to read the idempotency key from. Defaults to `Idempotency-Key`. | | ||
|
|
||
| When `requireKey` is set to `false`, requests without the header bypass idempotency protection entirely and execute normally. | ||
|
|
||
| ### Class-level application | ||
|
|
||
| The `#[Idempotent]` attribute can be applied at the class level to make all routes in a controller idempotent: | ||
|
|
||
| ```php app/ApiOrderController.php | ||
| use Tempest\Router\Post; | ||
| use Tempest\Router\Patch; | ||
| use Tempest\Http\Response; | ||
| use Tempest\Idempotency\Attributes\Idempotent; | ||
|
|
||
| #[Idempotent] | ||
| final readonly class ApiOrderController | ||
| { | ||
| #[Post('/api/orders')] | ||
| public function create(CreateOrderRequest $request): Response { /* … */ } | ||
|
|
||
| #[Patch('/api/orders/{id}')] | ||
| public function update(int $id, UpdateOrderRequest $request): Response { /* … */ } | ||
| } | ||
| ``` | ||
|
|
||
| ### Response behavior | ||
|
|
||
| The middleware produces different responses depending on the state of the idempotency key: | ||
|
|
||
| | Scenario | Status | Description | | ||
| |---|---|---| | ||
| | No existing record | - | The request executes normally and the response is cached. | | ||
| | Completed record, same payload | Original status | The cached response is replayed with an `idempotency-replayed: true` header. | | ||
| | Completed record, different payload | 422 | The key was already used with a different request body. | | ||
| | Pending record (in progress) | 409 | Another request with the same key is currently being processed. A `retry-after: 1` header is included. | | ||
| | Missing key (when required) | 400 | The `Idempotency-Key` header was not provided. | | ||
|
|
||
| ### How it works | ||
|
|
||
| The `#[Idempotent]` attribute is a [route decorator](../1-essentials/01-routing.md#route-decorators) that adds {b`Tempest\Idempotency\Middleware\IdempotencyMiddleware`} to the route's middleware stack. The middleware: | ||
|
|
||
| 1. Reads the idempotency key from the request header. | ||
| 2. Computes a fingerprint of the request (method, URI, body, and query parameters). | ||
| 3. Acquires a cache lock to prevent concurrent processing of the same key. | ||
| 4. Checks for an existing record in the idempotency store. | ||
| 5. If no record exists, saves a pending record, executes the handler, and stores the completed response. | ||
| 6. If the handler throws an exception, the pending record is deleted so the request can be retried. | ||
|
|
||
| A heartbeat mechanism keeps pending records alive during long-running requests, preventing other processes from incorrectly taking over an operation that is still in progress. | ||
|
|
||
| ## Idempotent commands | ||
|
|
||
| Mark a command class with {b`Tempest\Idempotency\Attributes\IdempotentCommand`} to prevent duplicate dispatches. When the same command is dispatched more than once, the duplicate is silently skipped. | ||
|
|
||
| ```php app/ImportInvoicesCommand.php | ||
| use Tempest\Idempotency\Attributes\IdempotentCommand; | ||
|
|
||
| #[IdempotentCommand] | ||
| final readonly class ImportInvoicesCommand | ||
| { | ||
| public function __construct( | ||
| public string $vendorId, | ||
| public string $month, | ||
| ) {} | ||
| } | ||
| ``` | ||
|
|
||
| The handler is defined as usual with `#[CommandHandler]`: | ||
|
|
||
| ```php app/ImportInvoicesHandler.php | ||
| use Tempest\CommandBus\CommandHandler; | ||
|
|
||
| final class ImportInvoicesHandler | ||
| { | ||
| #[CommandHandler] | ||
| public function handleImportInvoices(ImportInvoicesCommand $command): void | ||
| { | ||
| // Only executes once per unique command payload. | ||
| // Duplicate dispatches are silently skipped. | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| By default, the idempotency key is derived from a fingerprint of the command's properties. Two commands with identical property values produce the same fingerprint and are considered duplicates. | ||
|
|
||
| ### Explicit idempotency keys | ||
|
|
||
| Commands can provide an explicit key by implementing the {b`Tempest\Idempotency\Contracts\HasIdempotencyKey`} interface. This is useful when the deduplication key should be a specific business identifier rather than the full payload: | ||
|
|
||
| ```php app/ProcessPaymentCommand.php | ||
| use Tempest\Idempotency\Attributes\IdempotentCommand; | ||
| use Tempest\Idempotency\Contracts\HasIdempotencyKey; | ||
|
|
||
| #[IdempotentCommand] | ||
| final readonly class ProcessPaymentCommand implements HasIdempotencyKey | ||
| { | ||
| public function __construct( | ||
| public string $paymentId, | ||
| public int $amount, | ||
| ) {} | ||
|
|
||
| public function getIdempotencyKey(): string | ||
| { | ||
| return $this->paymentId; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| When using explicit keys, the fingerprint of the command payload is still verified. If the same key is dispatched with a different payload, an {b`Tempest\Idempotency\Exceptions\IdempotencyKeyWasAlreadyUsed`} exception is thrown. | ||
|
|
||
| ### Per-command TTL overrides | ||
|
|
||
| The `#[IdempotentCommand]` attribute accepts optional TTL parameters: | ||
|
|
||
| ```php | ||
| #[IdempotentCommand(ttlInSeconds: 3600, pendingTtlInSeconds: 30)] | ||
| final readonly class ProcessPaymentCommand { /* … */ } | ||
| ``` | ||
|
|
||
| | Parameter | Type | Description | | ||
| |---|---|---| | ||
| | `ttlInSeconds` | `?int` | How long the completed record is cached. Defaults to the config value (86400 / 24 hours). | | ||
| | `pendingTtlInSeconds` | `?int` | How long a pending record is considered active. Defaults to the config value (60 seconds). | | ||
|
|
||
| ## Configuration | ||
|
|
||
| The idempotency package is configured by creating an `idempotency.config.php` file. All settings have sensible defaults: | ||
|
|
||
| ```php app/idempotency.config.php | ||
| use Tempest\Idempotency\Config\IdempotencyConfig; | ||
|
|
||
| return new IdempotencyConfig( | ||
| header: 'Idempotency-Key', | ||
| requireKey: true, | ||
| ttlInSeconds: 86_400, | ||
| pendingTtlInSeconds: 60, | ||
| cachePrefix: 'idempotency', | ||
| ); | ||
| ``` | ||
|
|
||
| | Parameter | Default | Description | | ||
| |---|---|---| | ||
| | `header` | `Idempotency-Key` | The HTTP header name to read the idempotency key from. | | ||
| | `requireKey` | `true` | Whether to reject requests that do not include the idempotency key header. | | ||
| | `ttlInSeconds` | `86400` (24h) | How long a completed response is cached. | | ||
| | `pendingTtlInSeconds` | `60` | How long a pending record is considered active before it can be taken over. | | ||
| | `cachePrefix` | `idempotency` | Prefix for cache keys in the idempotency store. | | ||
| | `storeClass` | `CacheIdempotencyStore` | The {b`Tempest\Idempotency\Store\IdempotencyStore`} implementation to use. | | ||
|
|
||
| ### Custom stores | ||
|
|
||
| The default store uses Tempest's [cache](./06-cache.md) component. A custom store can be created by implementing the {b`Tempest\Idempotency\Store\IdempotencyStore`} interface and setting the `storeClass` in the configuration: | ||
|
|
||
| ```php app/idempotency.config.php | ||
| use Tempest\Idempotency\Config\IdempotencyConfig; | ||
| use App\RedisIdempotencyStore; | ||
|
|
||
| return new IdempotencyConfig( | ||
| storeClass: RedisIdempotencyStore::class, | ||
| ); | ||
| ``` | ||
|
|
||
| ## Limitations | ||
|
|
||
| - **Windows is not supported.** The heartbeat mechanism relies on `pcntl_alarm` and `pcntl_signal`, which are not available on Windows. Attempting to use idempotency on Windows will throw an {b`Tempest\Idempotency\Exceptions\IdempotencyPlatformWasNotSupported`} exception. | ||
| - **Stored responses must be serializable.** Response bodies are stored using PHP serialization or JSON encoding. Non-serializable bodies (such as generators or views) are stored as type name strings and will not reproduce the original output on replay. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.