Skip to main content

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: if True, 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-After value 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_session becomes test_cancel_checkout_session_idempotent
  • test_successfully_cancel_checkout_session_with_previous_status_created becomes test_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:

  • POST
  • PUT
  • PATCH

It is ignored for:

  • GET
  • DELETE
  • OPTIONS
  • HEAD
  • 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)

Source distribution for drf-idempotencykey 0.1.0
File Size Uploaded
drf_idempotencykey-0.1.0.tar.gz 18.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for drf-idempotencykey 0.1.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page