drf-idempotencykey
Idempotent API requests for Django REST Framework, built for real-world services that need safe retry behavior without duplicate writes.
This package stores a request fingerprint per user and idempotency key, reuses the original response for repeat requests, and rejects mismatched payloads or method changes with clear HTTP 400/409 responses.
Why this package? 🚀
When the same client retries a POST, PUT, or PATCH request due to a timeout or mobile reconnect, a backend API should not create duplicate side effects. This package solves that by binding each idempotency key to:
- the authenticated user
- the HTTP method
- the request path
- the request body hash
- the original response payload
If the same key is replayed with the same payload, the API returns the cached response. If the same key is reused with different data, the request is rejected as a conflict.
Requirements ✅
- Python 3.11+
- Django 5.2+
- Django REST Framework 3.15+
Installation 📦
Using uv:
uv add drf-idempotencykey
Or with pip:
pip install drf-idempotencykey
Then add it to your Django project:
INSTALLED_APPS = [
# ...
"rest_framework",
"drf_idempotencykey",
]
Quick start ⚡
Use the mixin on any DRF view that should be idempotent:
from rest_framework.views import APIView
from rest_framework.response import Response
from drf_idempotencykey.mixins import DrfIdempotencyKeyMixin
class CreateInvoiceView(DrfIdempotencyKeyMixin, APIView):
def post(self, request):
# Your side-effecting logic goes here
return Response({"status": "created"})
Then send the Idempotency-Key header from the client:
POST /api/invoices/
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
The same request replayed with the same key and body will return the original response instead of running the action again.
How it works 🔄
The package links each request to a unique (user, key) record and then verifies the request signature before allowing a retry to reuse the cached response. The key is derived from the authenticated user, the idempotency key itself, the request method, the request path, and a hash of the request body.
sequenceDiagram
autonumber
participant C as Client
participant V as DRF View
participant M as DrfIdempotencyKeyMixin
participant DB as Database
C->>V: POST /invoices/ with Idempotency-Key
V->>M: _pre_idempotent_response()
M->>M: validate UUID + check auth + method/path rules
M->>DB: get_or_create(IdempotencyKey by user + key)
alt same key + same method + same path + same body hash
DB-->>M: existing record
M->>M: if response already saved -> return cached response
else same key + different body/method/path
DB-->>M: record exists but digest mismatch
M-->>C: 409 conflict
else record currently in progress
DB-->>M: response_code is NULL
M-->>C: 409 with Retry-After
else first time / fresh request
DB-->>M: new record created
M->>V: continue normal view execution
V-->>M: response
M->>DB: select_for_update() + save_response()
M-->>C: original response
end
A few important details:
- The request fingerprint is tied to the authenticated user, so the same idempotency key is not shared across users.
- The record is protected with a
select_for_update()lock when checking or saving the response, so concurrent duplicates are serialized. - If a request is still in progress, the second caller gets a 409 with
Retry-After; if the key is reused with different request parameters, it also gets a 409. - Once a successful response is saved, later retries with the same request payload return the original cached body.
Configuration 🛠️
You can customize the package behavior in settings.py:
IDEMPOTENCY_KEY_EXPIRATION_MINUTES = 60
IDEMPOTENCY_KEY_CLEANUP_INTERVAL_HOURS = 24
IDEMPOTENCY_KEY_EXEMPT_PATH_RE = r"^/healthz/?$|^/auth/"
IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"
Settings
These are the settings currently supported by the package:
IDEMPOTENCY_KEY_EXPIRATION_MINUTES: how long a cached idempotency key stays valid before it can be reused for a fresh request. Default:60.IDEMPOTENCY_KEY_EXEMPT_PATH_RE: regex for paths to skip idempotency enforcement on, for example health checks and auth endpoints. Default:"".IDEMPOTENCY_KEY_HEADER: HTTP header name to read for the idempotency key. Default:"Idempotency-Key".IDEMPOTENCY_KEY_METHODS: iterable of HTTP methods that participate in idempotency enforcement. Default:("POST", "PUT", "PATCH").IDEMPOTENCY_KEY_REQUIRED: ifTrue, requests on configured methods without the idempotency header are rejected with a 400 before the view runs. Default:False.IDEMPOTENCY_KEY_MAX_BODY_SIZE: maximum stored payload size in bytes before the library skips persisting request/response bodies and logs a warning. Default: unset/disabled.IDEMPOTENCY_KEY_RETRY_AFTER_SECONDS:Retry-Aftervalue attached to the in-progress 409 response when a duplicate request is already being processed. Default:5.IDEMPOTENCY_KEY_CLEANUP_INTERVAL_HOURS: legacy scheduling hint for external cron/beat jobs. The package has a shared.expired()queryset and does not currently use this value in the runtime cleanup logic itself; schedule the job outside the app as needed. Default:24.
This is the complete current public surface of settings in the package. Additional settings are not necessary today; the most plausible future candidates would be a stricter global request redaction policy, a custom expiry predicate, an per-view opt-in/opt-out registry, or a custom retry policy hook, but those would be additive and should be introduced only if real production needs appear.
Testing helpers 🧪
This repo also includes a small testing toolkit you can reuse in your own Django project to validate the same endpoint both with and without an idempotency key.
from rest_framework.test import APITestCase
from drf_idempotencykey.testing import IdempotencyAPIClient
class MyEndpointTests(APITestCase):
def test_endpoint_without_idempotency_key(self):
response = self.client.post("/api/invoices/", {"amount": 10}, format="json")
self.assertEqual(response.status_code, 200)
def test_endpoint_with_idempotency_key(self):
client = IdempotencyAPIClient()
client.force_authenticate(user=self.user)
response = client.post(
"/api/invoices/",
{"amount": 10},
format="json",
HTTP_IDEMPOTENCY_KEY="550e8400-e29b-41d4-a716-446655440000",
)
self.assertEqual(response.status_code, 200)
For tests where you want the repeated-request check to be automatic, use the decorator:
from drf_idempotencykey.testing import add_idempotency_test
class CheckoutSessionTests(APITestCase):
@add_idempotency_test
def test_cancel_checkout_session(self):
checkout_session = CheckoutSessionFactory(shop=self.shop)
response = self.cancel_checkout_session(checkout_session.api_id, {"cancellation_reason": "duplicate"})
self.assertEqual(response.status_code, 204)
That decorator runs the test once, then replays the same request with the same idempotency key and checks that the cached response is returned.
The generated helper test always follows the pattern <original_test_name>_idempotent. For example:
test_cancel_checkout_sessionbecomestest_cancel_checkout_session_idempotenttest_successfully_cancel_checkout_session_with_previous_status_createdbecomestest_successfully_cancel_checkout_session_with_previous_status_created_idempotent
The original test checks the standard behavior; the generated one verifies the retry-safe behavior by replaying the exact same request with the same idempotency key and asserting the cached response is returned.
API behavior 🔍
The mixin is active for these HTTP verbs only:
POSTPUTPATCH
It is ignored for:
GETDELETEOPTIONSHEAD- unauthenticated requests
- paths matching
IDEMPOTENCY_KEY_EXEMPT_PATH_RE
Duplicate retry response
On a repeat request with the same key, method, path, and payload, the API returns the cached response, including the header:
Cached-From-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Conflict response
If the same idempotency key is reused with a different payload or method, the API responds with a 409 error:
{
"status_code": 409,
"title": "Idempotency key error",
"errors": [
{
"error_code": "IDEMPOTENCY_KEY_IN_USE_WITH_DIFFERENT_REQUEST",
"reason": "Request parameters do not match the original request."
}
]
}
Security & data retention 🔐
This package intentionally stores a small amount of request metadata for replay safety, but it does not guarantee that raw request or response bodies are safe to persist in production. Full payloads may include passwords, API tokens, card numbers, PII, or other sensitive values.
To avoid storing raw sensitive material, override the mixin hook on your view class:
from drf_idempotencykey.mixins import DrfIdempotencyKeyMixin
class CreateInvoiceView(DrfIdempotencyKeyMixin):
@staticmethod
def redact_body(body: str) -> str:
# replace or strip sensitive fields before persisting
return "[REDACTED]"
The default implementation is a no-op, so if you do not override it the original body text will be stored verbatim. This is convenient for development but should not be considered production-safe by default for sensitive endpoints.
In addition, the project removes request_body from the default Django admin search fields so it is not exposed through the admin UI by default, but you should still treat the field as sensitive data and plan for a retention policy or redaction layer.
Binary / non-UTF-8 payloads 📦
This package stores the request fingerprint and response body metadata for replayed idempotent requests. The request body is stored as a UTF-8 text representation with replacement characters for invalid bytes, and the response body is cached as text when the response is text-like (application/json, text/*, or XML). For binary payloads or other content types that cannot be represented safely as UTF-8 text, the package skips persisting the raw response body and keeps the idempotency record only for the request metadata and status code.
This is a deliberate safety tradeoff: it avoids crashing on binary downloads or other non-text responses, but it means binary or opaque response payloads are not replayed byte-for-byte. If your API serves file downloads or binary payloads, treat these endpoints as unsupported for strict response-body replay semantics.
Optional cleanup task 🧹
This package includes a cleanup task for expired idempotency records. If you use Celery, register a periodic task in your project:
from celery.schedules import crontab
from celery import Celery
app.conf.beat_schedule = {
"cleanup-idempotency-keys": {
"task": "drf_idempotencykey.tasks.cleanup_idempotency_keys",
"schedule": crontab(hour=3, minute=0),
},
}
If you are not using Celery, the model can still be used as a normal Django app; the task is optional. You can prune old records from any scheduler or cron job with:
python manage.py cleanup_idempotency_keys
Example project configuration 🧩
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"drf_idempotencykey",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
]
}
IDEMPOTENCY_KEY_EXPIRATION_MINUTES = 60
IDEMPOTENCY_KEY_EXEMPT_PATH_RE = r"^/healthz/?$|^/auth/"
Contributing 🤝
Install the dev environment with uv:
uv sync --group dev
uv run ruff check .
uv run python -m django test tests.test_mixin --settings=tests.settings
License 📄
MIT
Release files for drf-idempotencykey 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| drf_idempotencykey-0.1.0.tar.gz | 18.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| drf_idempotencykey-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 35.4 kB
Release files / drf_idempotencykey-0.1.0.tar.gz
| Download URL | drf_idempotencykey-0.1.0.tar.gz |
|---|---|
| Size | 18.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a97cc5d5fe58478c5901c47ef7ab653499417f210458487537db458879bb0ad5
|
|
BLAKE2b-256 checksum How to use checksums |
3bbeb6b85c333524f501c73d8b49ee5faab73693da7180dcf3aff8bae610ab7b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.
Transparency logRelease files / drf_idempotencykey-0.1.0-py3-none-any.whl
| Download URL | drf_idempotencykey-0.1.0-py3-none-any.whl |
|---|---|
| Size | 16.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
23fb9354c90073753cc887d3a9092ed4d5b062cdefccfd809e155a5a941d4bdb
|
|
BLAKE2b-256 checksum How to use checksums |
ee7fbfaa9a35543f2e1b65f1003c4ca7cdace677c299b028d9a24037919028b5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.
Transparency log