Skip to content

Commit 47e0de7

Browse files
authored
feat(idempotency): add idempotency package (#1997)
1 parent b63eee8 commit 47e0de7

46 files changed

Lines changed: 3279 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
/packages/http/ @brendt @aidan-casey
3535
/packages/http-client/ @aidan-casey
3636
/packages/icon/ @innocenzi
37+
/packages/idempotency/ @xHeaven
3738
/packages/kv-store/ @innocenzi
3839
/packages/log/ @brendt
3940
/packages/mail/ @innocenzi

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"tempest/http": "self.version",
111111
"tempest/http-client": "self.version",
112112
"tempest/icon": "self.version",
113+
"tempest/idempotency": "self.version",
113114
"tempest/intl": "self.version",
114115
"tempest/kv-store": "self.version",
115116
"tempest/log": "self.version",
@@ -151,6 +152,7 @@
151152
"Tempest\\HttpClient\\": "packages/http-client/src",
152153
"Tempest\\Http\\": "packages/http/src",
153154
"Tempest\\Icon\\": "packages/icon/src",
155+
"Tempest\\Idempotency\\": "packages/idempotency/src",
154156
"Tempest\\Intl\\": "packages/intl/src",
155157
"Tempest\\KeyValue\\": "packages/kv-store/src",
156158
"Tempest\\Log\\": "packages/log/src",
@@ -220,6 +222,7 @@
220222
"Tempest\\HttpClient\\Tests\\": "packages/http-client/tests",
221223
"Tempest\\Http\\Tests\\": "packages/http/tests",
222224
"Tempest\\Icon\\Tests\\": "packages/icon/tests",
225+
"Tempest\\Idempotency\\Tests\\": "packages/idempotency/tests",
223226
"Tempest\\Intl\\Tests\\": "packages/intl/tests",
224227
"Tempest\\KeyValue\\Tests\\": "packages/kv-store/tests",
225228
"Tempest\\Log\\Tests\\": "packages/log/tests",

docs/1-essentials/01-routing.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,39 @@ final readonly class Auth implements RouteDecorator
678678
}
679679
```
680680

681+
## Idempotent routes
682+
683+
For operations like payment processing or order creation, retrying the same request should not produce duplicate side effects. Tempest provides the {b`Tempest\Idempotency\Attributes\Idempotent`} route decorator to handle this. Clients send an `Idempotency-Key` header; the first request executes normally and caches the response, while subsequent requests with the same key replay the cached response.
684+
685+
```php app/OrderController.php
686+
use Tempest\Router\Post;
687+
use Tempest\Http\Response;
688+
use Tempest\Http\GenericResponse;
689+
use Tempest\Http\Status;
690+
use Tempest\Idempotency\Attributes\Idempotent;
691+
692+
final readonly class OrderController
693+
{
694+
#[Post('/orders')]
695+
#[Idempotent]
696+
public function create(CreateOrderRequest $request): Response
697+
{
698+
$order = $this->orderService->create($request);
699+
700+
return new GenericResponse(
701+
status: Status::CREATED,
702+
body: ['id' => $order->id],
703+
);
704+
}
705+
}
706+
```
707+
708+
Idempotency is only supported for `POST` and `PATCH` routes. The attribute can be applied at the class level to make all routes in a controller idempotent, and accepts optional TTL parameters. Route-specific settings like key requirement and header name can be configured with the `#[IdempotentRoute]` attribute.
709+
710+
:::info
711+
Read the full [idempotency documentation](../2-features/19-idempotency.md) for details on scope resolvers, configuration, response behavior, and command bus idempotency.
712+
:::
713+
681714
## Responses
682715

683716
All requests to a controller action expect a response to be returned to the client. This is done by returning a {b`Tempest\View\View`} or a {b`Tempest\Http\Response`} object.

docs/2-features/10-command-bus.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,70 @@ In order to _run_ an asynchronous command, you'll have to run the `tempest comma
115115

116116
Note that async command handling is still an early feature, and will receive many improvements over time.
117117

118+
## Idempotent commands
119+
120+
Commands that should not be processed more than once—such as payment processing or invoice imports—can be marked with {b`Tempest\Idempotency\Attributes\Idempotent`}. The attribute can be placed on the command class or on the handler method. Duplicate dispatches with the same payload are silently skipped.
121+
122+
```php
123+
// app/ImportInvoicesCommand.php
124+
125+
use Tempest\Idempotency\Attributes\Idempotent;
126+
127+
#[Idempotent]
128+
final readonly class ImportInvoicesCommand
129+
{
130+
public function __construct(
131+
public string $vendorId,
132+
public string $month,
133+
) {}
134+
}
135+
```
136+
137+
Alternatively, the attribute can be placed on the handler method instead:
138+
139+
```php
140+
// app/ImportInvoicesHandler.php
141+
142+
use Tempest\CommandBus\CommandHandler;
143+
use Tempest\Idempotency\Attributes\Idempotent;
144+
145+
final class ImportInvoicesHandler
146+
{
147+
#[Idempotent]
148+
#[CommandHandler]
149+
public function handle(ImportInvoicesCommand $command): void { /* … */ }
150+
}
151+
```
152+
153+
By default, the deduplication key is derived from the command's properties. Two commands with identical property values are considered duplicates. For explicit control over the key, implement the {b`Tempest\Idempotency\HasIdempotencyKey`} interface:
154+
155+
```php
156+
// app/ProcessPaymentCommand.php
157+
158+
use Tempest\Idempotency\Attributes\Idempotent;
159+
use Tempest\Idempotency\HasIdempotencyKey;
160+
161+
#[Idempotent]
162+
final readonly class ProcessPaymentCommand implements HasIdempotencyKey
163+
{
164+
public function __construct(
165+
public string $paymentId,
166+
public int $amount,
167+
) {}
168+
169+
public function getIdempotencyKey(): string
170+
{
171+
return $this->paymentId;
172+
}
173+
}
174+
```
175+
176+
When using explicit keys, the payload fingerprint is still verified. Dispatching the same key with a different payload throws {b`Tempest\Idempotency\Exceptions\IdempotencyKeyWasAlreadyUsed`}.
177+
178+
:::info
179+
Read the full [idempotency documentation](./19-idempotency.md) for details on configuration, TTL overrides, custom stores, and HTTP route idempotency.
180+
:::
181+
118182
## Command bus middleware
119183

120184
Whenever commands are dispatched, they are passed to the command bus, which will pass the command along to each of its handlers. Similar to web requests and console commands, this command bus supports middleware. Command bus middleware can be used to, for example, do logging for specific commands, add metadata to commands, or anything else. Command bus middleware are classes that implement the `CommandBusMiddleware` interface, and look like this:

0 commit comments

Comments
 (0)