alkinbg / http-idempotency-bundle
HTTP request idempotency for Symfony using Idempotency-Key, request fingerprints, shared state, and Symfony Lock.
Package info
github.com/alkinbg/http-idempotency-bundle
Type:symfony-bundle
pkg:composer/alkinbg/http-idempotency-bundle
Requires
- php: >=8.2
- psr/cache: ^3.0
- symfony/config: ^7.4 || ^8.1
- symfony/dependency-injection: ^7.4 || ^8.1
- symfony/event-dispatcher: ^7.4 || ^8.1
- symfony/http-foundation: ^7.4 || ^8.1
- symfony/http-kernel: ^7.4 || ^8.1
- symfony/lock: ^7.4 || ^8.1
- symfony/security-core: ^7.4 || ^8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.90
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.5 || ^12.5
- predis/predis: ^2.3 || ^3.0
- symfony/browser-kit: ^7.4 || ^8.1
- symfony/cache: ^7.4 || ^8.1
- symfony/framework-bundle: ^7.4 || ^8.1
- symfony/routing: ^7.4 || ^8.1
- symfony/runtime: ^7.4 || ^8.1
- symfony/security-bundle: ^7.4 || ^8.1
README
HTTP request idempotency for Symfony controllers using Idempotency-Key, deterministic request fingerprints, shared state, and Symfony Lock.
Important
This bundle does not provide exactly-once execution. It reduces duplicate execution for retried HTTP requests by coordinating a shared idempotency record and a lock. There is still an unavoidable failure window between an application's side effect and persistence of the completed idempotency record. Provider-level idempotency, database constraints, transactions, outbox patterns, and other domain guarantees remain complementary.
The package is stable as of 1.0. The public API listed under Extension interfaces is covered by semantic-versioning compatibility commitments throughout the 1.x series.
What problem this solves
Clients retry requests. Mobile networks fail, reverse proxies time out, workers restart, and users double-submit forms. For non-safe operations, a retry can accidentally execute the same business action twice.
HttpIdempotencyBundle lets an application explicitly mark a controller as idempotent. Requests to that controller must provide an idempotency key. The bundle fingerprints the request, coordinates execution with shared storage and a non-blocking Symfony lock, stores a replayable response, and returns that stored response for an identical retry without calling the controller again.
It is deliberately opt-in. Unmarked controllers are not touched.
Requirements
- PHP
>= 8.2 - Symfony
7.4or8.1 - a PSR-6 cache pool
- a Symfony Lock factory
For multi-process or multi-node production deployments, the cache and lock backends must be shared by every application instance.
Installation
composer require alkinbg/http-idempotency-bundle
Enable without Symfony Flex
Add the bundle to config/bundles.php:
<?php return [ // ... Alkin\HttpIdempotencyBundle\AlkinHttpIdempotencyBundle::class => ['all' => true], ];
Basic usage
Mark a controller method or the whole controller class with #[Idempotent]:
<?php namespace App\Controller; use Alkin\HttpIdempotencyBundle\Attribute\Idempotent; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; final class OrderController { #[Route('/orders', name: 'orders_create', methods: ['POST'])] #[Idempotent] public function create(): JsonResponse { // Perform the application operation. return new JsonResponse(['created' => true], 201); } }
The client sends one idempotency key:
POST /orders HTTP/1.1 Content-Type: application/json Idempotency-Key: order-7f98b773 {"sku":"ABC-42","quantity":1}
An identical retry with the same key replays the stored response instead of executing the controller again.
HTTP outcomes
For a protected controller, the bundle can return these problem responses before application execution:
| Status | Meaning |
|---|---|
400 Bad Request |
The idempotency key is missing, empty, duplicated as visible to HttpFoundation, contains control characters, or exceeds the configured maximum length. |
409 Conflict |
Another request for the same idempotency identity is still processing, or the non-blocking execution lock cannot be acquired. |
422 Unprocessable Content |
The same idempotency key was already used for a different request fingerprint. |
503 Service Unavailable |
The storage/lock backend fails or lock ownership cannot be safely verified. |
Protected StreamedResponse and BinaryFileResponse results are rejected explicitly with a 500 problem response because they cannot be safely snapshotted by the default response policy.
The bundle uses application/problem+json for its own error responses and does not include the idempotency key, fingerprint, lock resource, or backend exception details in them.
Configuration
Defaults:
# config/packages/http_idempotency.yaml http_idempotency: header: Idempotency-Key replay_ttl: 86400 processing_ttl: 300 lock_ttl: 300 max_key_length: 255 cache_pool: cache.app lock_factory: lock.factory
replay_ttl controls how long a completed response remains replayable. processing_ttl controls how long an unfinished processing marker is retained. lock_ttl is the maximum expected lock duration passed to Symfony Lock. max_key_length is measured in bytes.
Production Redis example
The important requirement is not Redis specifically; it is that both state and locking are shared across all workers and nodes. Redis is one practical way to provide that.
framework: cache: pools: cache.http_idempotency: adapter: cache.adapter.redis provider: '%env(REDIS_URL)%' lock: http_idempotency: '%env(REDIS_URL)%' http_idempotency: cache_pool: cache.http_idempotency lock_factory: lock.http_idempotency.factory replay_ttl: 86400 processing_ttl: 300 lock_ttl: 300
Use the service id generated by your Symfony lock configuration for lock_factory.
Lock TTL and request duration
lock_ttl must be longer than the maximum time a protected request is expected to run. If ownership is lost or the lock has expired before the response is persisted, the bundle fails closed with 503 rather than recording a completed response it can no longer prove it owns.
A very large lock TTL is not a substitute for correct request-duration limits. Choose the value from the real upper bound of the operation and infrastructure timeouts.
Operation, scope, fingerprint, and identity
These concepts are intentionally separate.
Operation identifies the protected application action. Named Symfony routes are preferred because they provide a stable deterministic operation name. If no usable named route/controller identity can be derived, protection fails rather than silently creating an unstable identity.
Scope separates principals. The default scope is anonymous when no Symfony UserInterface is available. Authenticated requests use the user class and getUserIdentifier() so the same key can be used independently by different users.
Fingerprint describes the concrete request. The default SHA-256 fingerprint includes method, operation, path, normalized query parameters, normalized Content-Type, and raw request body. A changed payload with the same key therefore produces 422 instead of replaying an unrelated response. Custom RequestFingerprintInterface implementations must return a lowercase 64-character SHA-256 hexadecimal digest; the bundle validates this before any storage or lock backend access.
Idempotency identity is a SHA-256-derived storage/lock identity built from scope, operation, and key. Raw keys are not used as cache or lock resource names.
Anonymous multi-principal warning
All unauthenticated requests share the default anonymous scope. If one endpoint serves multiple independent principals before Symfony authentication is established, provide a custom ScopeResolverInterface implementation that returns a stable non-sensitive scope for the real principal. Do not put secrets or credentials into the scope string.
Response replay behavior
The first successful execution is captured as status, body, and eligible headers. Application 4xx and 5xx responses are also completed responses and are replayed; an application error is not automatically treated as a failed idempotency operation.
The default snapshot excludes hop-by-hop and environment-specific headers including:
ConnectionKeep-AliveProxy-AuthenticateProxy-AuthorizationTETrailerTransfer-EncodingUpgradeContent-LengthDateServerSet-Cookie
Response cookies are intentionally not replayed.
Response listener ordering
The bundle captures its snapshot early in kernel.response at priority 1024. Normal response listeners with lower priorities then continue to mutate the outgoing response.
On a replay, the reconstructed response goes through kernel.response again. This means ordinary security headers, tracing headers, cookies, and other response-time behavior can be generated freshly instead of being copied from the original request.
The bundle cannot control third-party listeners registered at a priority higher than 1024; those listeners run before the bundle's capture point.
Duplicate header visibility
The bundle requires exactly one Idempotency-Key value as exposed by Symfony HttpFoundation. If HttpFoundation exposes multiple values, the request is rejected.
A web server, proxy, CDN, or PHP integration can theoretically coalesce duplicate physical header lines before the request reaches HttpFoundation. Once that wire-level distinction has been irreversibly lost, the bundle cannot reconstruct it. Configure upstream infrastructure to preserve or reject ambiguous duplicate idempotency headers.
Concurrency and failure model
For a new request the bundle performs an initial record read, attempts a non-blocking lock, and then performs a mandatory second read while holding the lock. That second read closes the race where another worker completed the operation between the initial read and lock acquisition.
The simplified state flow is:
record completed + same fingerprint -> replay
record completed + different fingerprint -> 422
lock unavailable / processing -> 409
no record after lock + second read -> save processing -> execute controller
response captured while lock is valid -> save completed -> release lock
backend/ownership failure -> fail closed
The processing marker is intentionally not deleted during abnormal cleanup. Its TTL provides a bounded recovery period after a crashed or interrupted request.
No exactly-once guarantee
There is an unavoidable class of failures where the controller's external side effect succeeds but the process dies before the completed response can be durably stored. A later retry may execute that side effect again after the processing marker expires.
For important writes, combine this bundle with domain-level guarantees such as:
- database uniqueness constraints;
- transactions and optimistic/pessimistic concurrency control;
- an outbox/inbox pattern;
- provider-native idempotency keys for payment or external APIs;
- durable business-operation identifiers.
HTTP idempotency coordination is one layer, not a replacement for those guarantees.
Safe HTTP methods
Applying #[Idempotent] to a method Symfony considers safe is treated as a developer configuration error. The bundle uses Request::isMethodSafe() rather than maintaining its own incomplete list.
Extension interfaces
Applications can replace the default behavior through these supported contracts:
ScopeResolverInterface— define principal/tenant scope;RequestFingerprintInterface— define request equivalence; implementations must return a lowercase 64-character SHA-256 hexadecimal digest;IdempotencyStoreInterface— provide another durable record store;ResponsePolicyInterface— decide which response types are eligible for capture.
Override the corresponding service alias in the application container.
The stable public package surface for the 1.x series is:
Attribute\Idempotent
Contract\ScopeResolverInterface
Contract\RequestFingerprintInterface
Contract\IdempotencyStoreInterface
Contract\ResponsePolicyInterface
ValueObject\IdempotencyIdentity
ValueObject\IdempotencyRecord
ValueObject\StoredResponse
AlkinHttpIdempotencyBundle
Other concrete classes are implementation details unless explicitly documented. They are not part of the supported backward-compatibility surface for 1.x.
Version and support policy
The first stable public release is 1.0.0.
The compatibility target is:
| Symfony | PHP |
|---|---|
7.4.* |
>= 8.2 |
8.1.* |
>= 8.4 as required by Symfony 8.1 |
Before each stable release, compatibility is checked against the supported dependency floor, latest supported dependencies, real Symfony skeleton applications, and a shared Redis cache/lock integration.
Security and privacy notes
- raw idempotency keys are not used as storage or lock resource names;
- backend exception details are not returned to clients;
- custom scopes should be stable but non-sensitive;
- custom request fingerprints must be SHA-256 digests rather than raw request data;
- response cookies are not persisted for replay;
- use a shared, access-controlled production backend;
- protect Redis or another backend with the same network and credential controls used for other application state.
Further documentation
See docs/index.md and the release checklist.
License
MIT.