Skip to content

API Reference

Generated in part from source docstrings via mkdocstrings.

Integration points

IdempotencyMiddleware

Applies idempotency-key handling to every matching request.

Parameters:

Name Type Description Default
get_response Callable[[HttpRequest], HttpResponse]

The next middleware/view in the chain, provided by Django's middleware machinery.

required
Source code in src/drf_idempotency/middleware.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class IdempotencyMiddleware:
    """Applies idempotency-key handling to every matching request.

    Args:
        get_response: The next middleware/view in the chain, provided by
            Django's middleware machinery.
    """

    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
        self.get_response = get_response

    def __call__(self, request: HttpRequest) -> HttpResponse:
        """Process one request.

        Args:
            request: The incoming request.

        Returns:
            The response — either freshly produced or replayed from a
            prior identical request. Requests whose method isn't in the
            configured ``METHODS`` setting pass through untouched.
            Idempotency-specific errors (invalid/missing key, key reuse,
            a concurrent request in progress) are returned as a JSON
            error response with the appropriate status code, rather than
            propagating as raw exceptions — plain Django middleware has
            no equivalent of DRF's own exception handling to do this
            automatically.
        """
        if not applies_to(request):
            return self.get_response(request)
        try:
            return process_idempotent_request(request, lambda: self.get_response(request))
        except IdempotencyError as exc:
            return build_error_response(exc)

__call__(request)

Process one request.

Parameters:

Name Type Description Default
request HttpRequest

The incoming request.

required

Returns:

Type Description
HttpResponse

The response — either freshly produced or replayed from a

HttpResponse

prior identical request. Requests whose method isn't in the

HttpResponse

configured METHODS setting pass through untouched.

HttpResponse

Idempotency-specific errors (invalid/missing key, key reuse,

HttpResponse

a concurrent request in progress) are returned as a JSON

HttpResponse

error response with the appropriate status code, rather than

HttpResponse

propagating as raw exceptions — plain Django middleware has

HttpResponse

no equivalent of DRF's own exception handling to do this

HttpResponse

automatically.

Source code in src/drf_idempotency/middleware.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def __call__(self, request: HttpRequest) -> HttpResponse:
    """Process one request.

    Args:
        request: The incoming request.

    Returns:
        The response — either freshly produced or replayed from a
        prior identical request. Requests whose method isn't in the
        configured ``METHODS`` setting pass through untouched.
        Idempotency-specific errors (invalid/missing key, key reuse,
        a concurrent request in progress) are returned as a JSON
        error response with the appropriate status code, rather than
        propagating as raw exceptions — plain Django middleware has
        no equivalent of DRF's own exception handling to do this
        automatically.
    """
    if not applies_to(request):
        return self.get_response(request)
    try:
        return process_idempotent_request(request, lambda: self.get_response(request))
    except IdempotencyError as exc:
        return build_error_response(exc)

idempotent

Apply idempotency-key handling to a single view function or method.

Parameters:

Name Type Description Default
methods Sequence[str] | None

HTTP methods this decorator applies to. Requests using any other method pass through untouched. Defaults to the METHODS setting (["POST", "PUT", "PATCH"] by default).

None

Returns:

Type Description
Callable[[_F], _F]

A decorator that wraps the view, applying the same

Callable[[_F], _F]

acquire-or-replay logic as

Callable[[_F], _F]

class:~drf_idempotency.middleware.IdempotencyMiddleware.

Example

.. code-block:: python

from rest_framework.decorators import api_view
from drf_idempotency import idempotent


@api_view(["POST"])
@idempotent()
def create_payment(request):
    ...


class PaymentViewSet(viewsets.ViewSet):
    @idempotent()
    def create(self, request):
        ...
Source code in src/drf_idempotency/decorators.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def idempotent(*, methods: Sequence[str] | None = None) -> Callable[[_F], _F]:
    """Apply idempotency-key handling to a single view function or method.

    Args:
        methods: HTTP methods this decorator applies to. Requests using
            any other method pass through untouched. Defaults to the
            ``METHODS`` setting (``["POST", "PUT", "PATCH"]`` by default).

    Returns:
        A decorator that wraps the view, applying the same
        acquire-or-replay logic as
        :class:`~drf_idempotency.middleware.IdempotencyMiddleware`.

    Example:
        .. code-block:: python

            from rest_framework.decorators import api_view
            from drf_idempotency import idempotent


            @api_view(["POST"])
            @idempotent()
            def create_payment(request):
                ...


            class PaymentViewSet(viewsets.ViewSet):
                @idempotent()
                def create(self, request):
                    ...
    """

    def decorator(func: _F) -> _F:
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> HttpResponse:
            request = _find_request(args)
            allowed_methods = {m.upper() for m in methods} if methods is not None else None
            if allowed_methods is not None and request.method not in allowed_methods:
                return func(*args, **kwargs)
            if allowed_methods is None and not applies_to(request):
                return func(*args, **kwargs)
            return process_idempotent_request(request, lambda: func(*args, **kwargs))

        return wrapper  # type: ignore[return-value]

    return decorator

Status tracking

get_idempotency_status

Return the current status of an idempotency key.

Parameters:

Name Type Description Default
key str

The idempotency key to look up.

required

Returns:

Type Description
str | None

"in_progress", "completed", or None if the key is

str | None

unknown (never used, or its record has expired). Note that a

str | None

request that failed (raised an exception, or produced an

str | None

uncached error response) releases its key entirely — see

str | None

docs/architecture.md — so a failed attempt is indistinguishable

str | None

from "never attempted" via this function; that is by design, since

str | None

it means the key remains free for a fresh, successful attempt.

Source code in src/drf_idempotency/status.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def get_idempotency_status(key: str) -> str | None:
    """Return the current status of an idempotency key.

    Args:
        key: The idempotency key to look up.

    Returns:
        ``"in_progress"``, ``"completed"``, or ``None`` if the key is
        unknown (never used, or its record has expired). Note that a
        request that failed (raised an exception, or produced an
        uncached error response) releases its key entirely — see
        ``docs/architecture.md`` — so a failed attempt is indistinguishable
        from "never attempted" via this function; that is by design, since
        it means the key remains free for a fresh, successful attempt.
    """
    record = get_backend().get(key)
    return record.status if record is not None else None

Fingerprinting

compute_fingerprint

Compute a stable fingerprint of a request's method, path, and body.

Parameters:

Name Type Description Default
request HttpRequest

The incoming request. request.body is read (and, per Django's own caching, becomes safe to read again later in the request/response cycle — e.g. by DRF's parsers).

required

Returns:

Type Description
str

A 64-character SHA-256 hex digest.

Source code in src/drf_idempotency/fingerprint.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def compute_fingerprint(request: HttpRequest) -> str:
    """Compute a stable fingerprint of a request's method, path, and body.

    Args:
        request: The incoming request. ``request.body`` is read (and, per
            Django's own caching, becomes safe to read again later in the
            request/response cycle — e.g. by DRF's parsers).

    Returns:
        A 64-character SHA-256 hex digest.
    """
    hasher = hashlib.sha256()
    hasher.update(request.method.encode("utf-8") if request.method else b"")
    hasher.update(b"\n")
    hasher.update(request.path.encode("utf-8"))
    hasher.update(b"\n")
    hasher.update(request.body)
    return hasher.hexdigest()

Backends

BaseBackend

Bases: ABC

Abstract base class for idempotency storage backends.

Subclass this and implement every abstract method to add a new storage backend. See :class:drf_idempotency.backends.redis.RedisBackend and :class:drf_idempotency.backends.database.DatabaseBackend for reference implementations.

Parameters:

Name Type Description Default
**options Any

Backend-specific keyword arguments, passed through from the BACKEND_OPTIONS setting.

{}
Source code in src/drf_idempotency/backends/base.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
class BaseBackend(ABC):
    """Abstract base class for idempotency storage backends.

    Subclass this and implement every abstract method to add a new
    storage backend. See :class:`drf_idempotency.backends.redis.RedisBackend`
    and :class:`drf_idempotency.backends.database.DatabaseBackend` for
    reference implementations.

    Args:
        **options: Backend-specific keyword arguments, passed through from
            the ``BACKEND_OPTIONS`` setting.
    """

    def __init__(self, **options: Any) -> None:
        self.options = options

    @abstractmethod
    def acquire_or_get(self, key: str, fingerprint: str, *, lock_ttl: timedelta) -> AcquireResult:
        """Atomically claim ``key``, or return its current state.

        This is the operation race-safety depends on: if two requests
        call this concurrently with the same ``key``, exactly one must
        receive ``AcquireResult(acquired=True, existing=None)`` and the
        other(s) must receive ``AcquireResult(acquired=False, existing=...)``.

        Args:
            key: The idempotency key to claim.
            fingerprint: The requesting request's fingerprint, stored
                alongside the key if this call acquires it.
            lock_ttl: How long the claim is held before it's considered
                abandoned and eligible to be reclaimed by a later call.

        Returns:
            An :class:`AcquireResult` describing whether this call
            claimed the key.
        """

    @abstractmethod
    def complete(
        self,
        key: str,
        *,
        status_code: int,
        headers: Mapping[str, str],
        body: bytes,
        ttl: timedelta,
    ) -> None:
        """Store the final response for a successfully-processed request.

        Args:
            key: The idempotency key (must currently be held by the
                caller, i.e. previously returned ``acquired=True``).
            status_code: The HTTP status code to replay on future lookups.
            headers: The HTTP headers to replay.
            body: The raw response body bytes to replay.
            ttl: How long the completed record remains eligible for
                replay before it expires.
        """

    @abstractmethod
    def fail(self, key: str) -> None:
        """Release a claimed key without storing a response.

        Called when request processing raised an unhandled exception or
        produced a server error (5xx) that should not be cached — the key
        becomes available for a fresh attempt.

        Args:
            key: The idempotency key to release.
        """

    @abstractmethod
    def get(self, key: str) -> StoredRecord | None:
        """Look up a key's current record without claiming it.

        Args:
            key: The idempotency key to look up.

        Returns:
            The current :class:`StoredRecord`, or ``None`` if the key is
            unknown (or has expired).
        """

    @abstractmethod
    def cleanup_expired(self) -> int:
        """Remove expired records.

        For backends with native TTL support (e.g. Redis), this is
        typically a no-op returning ``0``, since expiry is handled by the
        store itself. For backends without native expiry (e.g. the
        database backend), this performs the actual deletion and should
        be called periodically — see the ``cleanup_expired_idempotency_keys``
        management command.

        Returns:
            The number of records removed.
        """

acquire_or_get(key, fingerprint, *, lock_ttl) abstractmethod

Atomically claim key, or return its current state.

This is the operation race-safety depends on: if two requests call this concurrently with the same key, exactly one must receive AcquireResult(acquired=True, existing=None) and the other(s) must receive AcquireResult(acquired=False, existing=...).

Parameters:

Name Type Description Default
key str

The idempotency key to claim.

required
fingerprint str

The requesting request's fingerprint, stored alongside the key if this call acquires it.

required
lock_ttl timedelta

How long the claim is held before it's considered abandoned and eligible to be reclaimed by a later call.

required

Returns:

Name Type Description
An AcquireResult

class:AcquireResult describing whether this call

AcquireResult

claimed the key.

Source code in src/drf_idempotency/backends/base.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
@abstractmethod
def acquire_or_get(self, key: str, fingerprint: str, *, lock_ttl: timedelta) -> AcquireResult:
    """Atomically claim ``key``, or return its current state.

    This is the operation race-safety depends on: if two requests
    call this concurrently with the same ``key``, exactly one must
    receive ``AcquireResult(acquired=True, existing=None)`` and the
    other(s) must receive ``AcquireResult(acquired=False, existing=...)``.

    Args:
        key: The idempotency key to claim.
        fingerprint: The requesting request's fingerprint, stored
            alongside the key if this call acquires it.
        lock_ttl: How long the claim is held before it's considered
            abandoned and eligible to be reclaimed by a later call.

    Returns:
        An :class:`AcquireResult` describing whether this call
        claimed the key.
    """

cleanup_expired() abstractmethod

Remove expired records.

For backends with native TTL support (e.g. Redis), this is typically a no-op returning 0, since expiry is handled by the store itself. For backends without native expiry (e.g. the database backend), this performs the actual deletion and should be called periodically — see the cleanup_expired_idempotency_keys management command.

Returns:

Type Description
int

The number of records removed.

Source code in src/drf_idempotency/backends/base.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
@abstractmethod
def cleanup_expired(self) -> int:
    """Remove expired records.

    For backends with native TTL support (e.g. Redis), this is
    typically a no-op returning ``0``, since expiry is handled by the
    store itself. For backends without native expiry (e.g. the
    database backend), this performs the actual deletion and should
    be called periodically — see the ``cleanup_expired_idempotency_keys``
    management command.

    Returns:
        The number of records removed.
    """

complete(key, *, status_code, headers, body, ttl) abstractmethod

Store the final response for a successfully-processed request.

Parameters:

Name Type Description Default
key str

The idempotency key (must currently be held by the caller, i.e. previously returned acquired=True).

required
status_code int

The HTTP status code to replay on future lookups.

required
headers Mapping[str, str]

The HTTP headers to replay.

required
body bytes

The raw response body bytes to replay.

required
ttl timedelta

How long the completed record remains eligible for replay before it expires.

required
Source code in src/drf_idempotency/backends/base.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@abstractmethod
def complete(
    self,
    key: str,
    *,
    status_code: int,
    headers: Mapping[str, str],
    body: bytes,
    ttl: timedelta,
) -> None:
    """Store the final response for a successfully-processed request.

    Args:
        key: The idempotency key (must currently be held by the
            caller, i.e. previously returned ``acquired=True``).
        status_code: The HTTP status code to replay on future lookups.
        headers: The HTTP headers to replay.
        body: The raw response body bytes to replay.
        ttl: How long the completed record remains eligible for
            replay before it expires.
    """

fail(key) abstractmethod

Release a claimed key without storing a response.

Called when request processing raised an unhandled exception or produced a server error (5xx) that should not be cached — the key becomes available for a fresh attempt.

Parameters:

Name Type Description Default
key str

The idempotency key to release.

required
Source code in src/drf_idempotency/backends/base.py
129
130
131
132
133
134
135
136
137
138
139
@abstractmethod
def fail(self, key: str) -> None:
    """Release a claimed key without storing a response.

    Called when request processing raised an unhandled exception or
    produced a server error (5xx) that should not be cached — the key
    becomes available for a fresh attempt.

    Args:
        key: The idempotency key to release.
    """

get(key) abstractmethod

Look up a key's current record without claiming it.

Parameters:

Name Type Description Default
key str

The idempotency key to look up.

required

Returns:

Type Description
StoredRecord | None

The current :class:StoredRecord, or None if the key is

StoredRecord | None

unknown (or has expired).

Source code in src/drf_idempotency/backends/base.py
141
142
143
144
145
146
147
148
149
150
151
@abstractmethod
def get(self, key: str) -> StoredRecord | None:
    """Look up a key's current record without claiming it.

    Args:
        key: The idempotency key to look up.

    Returns:
        The current :class:`StoredRecord`, or ``None`` if the key is
        unknown (or has expired).
    """

StoredRecord

The stored state of one idempotency key.

Attributes:

Name Type Description
key str

The idempotency key this record belongs to.

fingerprint str

The fingerprint of the request that first used this key (see :func:drf_idempotency.fingerprint.compute_fingerprint).

status str

One of "in_progress", "completed", or "failed".

response_status_code int | None

The stored response's HTTP status code, or None if status is "in_progress".

response_headers Mapping[str, str] | None

The stored response's headers, or None if status is "in_progress".

response_body bytes | None

The stored response's raw body bytes, or None if status is "in_progress".

created_at datetime

When the key was first claimed.

Source code in src/drf_idempotency/backends/base.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@dataclass(frozen=True, slots=True)
class StoredRecord:
    """The stored state of one idempotency key.

    Attributes:
        key: The idempotency key this record belongs to.
        fingerprint: The fingerprint of the request that first used this
            key (see :func:`drf_idempotency.fingerprint.compute_fingerprint`).
        status: One of ``"in_progress"``, ``"completed"``, or ``"failed"``.
        response_status_code: The stored response's HTTP status code, or
            ``None`` if ``status`` is ``"in_progress"``.
        response_headers: The stored response's headers, or ``None`` if
            ``status`` is ``"in_progress"``.
        response_body: The stored response's raw body bytes, or ``None``
            if ``status`` is ``"in_progress"``.
        created_at: When the key was first claimed.
    """

    key: str
    fingerprint: str
    status: str
    response_status_code: int | None
    response_headers: Mapping[str, str] | None
    response_body: bytes | None
    created_at: datetime

AcquireResult

The outcome of calling :meth:BaseBackend.acquire_or_get.

Attributes:

Name Type Description
acquired bool

True if this call claimed the key and the caller should proceed to execute the request (and later call :meth:BaseBackend.complete or :meth:BaseBackend.fail). False if another request already holds or has completed this key.

existing StoredRecord | None

The current stored record, populated whenever acquired is False. None when acquired is True.

Source code in src/drf_idempotency/backends/base.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@dataclass(frozen=True, slots=True)
class AcquireResult:
    """The outcome of calling :meth:`BaseBackend.acquire_or_get`.

    Attributes:
        acquired: ``True`` if this call claimed the key and the caller
            should proceed to execute the request (and later call
            :meth:`BaseBackend.complete` or :meth:`BaseBackend.fail`).
            ``False`` if another request already holds or has completed
            this key.
        existing: The current stored record, populated whenever
            ``acquired`` is ``False``. ``None`` when ``acquired`` is
            ``True``.
    """

    acquired: bool
    existing: StoredRecord | None

DatabaseBackend

Bases: BaseBackend

Stores idempotency records in the database via :class:~drf_idempotency.models.IdempotencyRecord.

Requires "drf_idempotency" to be listed in INSTALLED_APPS and migrated (python manage.py migrate).

Source code in src/drf_idempotency/backends/database.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
class DatabaseBackend(BaseBackend):
    """Stores idempotency records in the database via
    :class:`~drf_idempotency.models.IdempotencyRecord`.

    Requires ``"drf_idempotency"`` to be listed in ``INSTALLED_APPS`` and
    migrated (``python manage.py migrate``).
    """

    def acquire_or_get(self, key: str, fingerprint: str, *, lock_ttl: timedelta) -> AcquireResult:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.acquire_or_get`."""
        now = timezone.now()
        with transaction.atomic():
            record, created = IdempotencyRecord.objects.get_or_create(
                key=key,
                defaults={
                    "fingerprint": fingerprint,
                    "status": IdempotencyRecord.STATUS_IN_PROGRESS,
                    "expires_at": now + lock_ttl,
                },
            )
        if created:
            return AcquireResult(acquired=True, existing=None)
        return self._handle_existing(record, fingerprint, now=now, lock_ttl=lock_ttl)

    def _handle_existing(
        self,
        record: IdempotencyRecord,
        fingerprint: str,
        *,
        now: Any,
        lock_ttl: timedelta,
    ) -> AcquireResult:
        if record.expires_at > now:
            return AcquireResult(acquired=False, existing=self._to_stored_record(record))

        # The record has expired. An in-progress lock past its TTL is
        # treated as abandoned (its worker likely crashed) and reclaimed;
        # an expired completed/failed record is deleted and a fresh
        # attempt is made, as if the key had never been used.
        if record.status == IdempotencyRecord.STATUS_IN_PROGRESS:
            reclaimed = IdempotencyRecord.objects.filter(
                pk=record.pk, status=IdempotencyRecord.STATUS_IN_PROGRESS, expires_at__lte=now
            ).update(fingerprint=fingerprint, expires_at=now + lock_ttl, created_at=now)
            if reclaimed:
                return AcquireResult(acquired=True, existing=None)
            record.refresh_from_db()
            return AcquireResult(acquired=False, existing=self._to_stored_record(record))

        deleted, _ = IdempotencyRecord.objects.filter(pk=record.pk, expires_at__lte=now).delete()
        if deleted:
            return self.acquire_or_get(record.key, fingerprint, lock_ttl=lock_ttl)
        record.refresh_from_db()
        return AcquireResult(acquired=False, existing=self._to_stored_record(record))

    def complete(
        self,
        key: str,
        *,
        status_code: int,
        headers: Mapping[str, str],
        body: bytes,
        ttl: timedelta,
    ) -> None:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.complete`."""
        now = timezone.now()
        IdempotencyRecord.objects.filter(key=key).update(
            status=IdempotencyRecord.STATUS_COMPLETED,
            response_status_code=status_code,
            response_headers=dict(headers),
            response_body=base64.b64encode(body).decode("ascii"),
            completed_at=now,
            expires_at=now + ttl,
        )

    def fail(self, key: str) -> None:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.fail`.

        Deletes the record entirely, so the next request using this key
        starts fresh rather than being permanently stuck.
        """
        IdempotencyRecord.objects.filter(key=key).delete()

    def get(self, key: str) -> StoredRecord | None:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.get`."""
        try:
            record = IdempotencyRecord.objects.get(key=key)
        except IdempotencyRecord.DoesNotExist:
            return None
        if record.expires_at <= timezone.now():
            return None
        return self._to_stored_record(record)

    def cleanup_expired(self) -> int:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.cleanup_expired`."""
        deleted, _ = IdempotencyRecord.objects.filter(expires_at__lte=timezone.now()).delete()
        return deleted

    @staticmethod
    def _to_stored_record(record: IdempotencyRecord) -> StoredRecord:
        body = base64.b64decode(record.response_body) if record.response_body else None
        return StoredRecord(
            key=record.key,
            fingerprint=record.fingerprint,
            status=record.status,
            response_status_code=record.response_status_code,
            response_headers=record.response_headers or None,
            response_body=body,
            created_at=record.created_at,
        )

acquire_or_get(key, fingerprint, *, lock_ttl)

See :meth:drf_idempotency.backends.base.BaseBackend.acquire_or_get.

Source code in src/drf_idempotency/backends/database.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def acquire_or_get(self, key: str, fingerprint: str, *, lock_ttl: timedelta) -> AcquireResult:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.acquire_or_get`."""
    now = timezone.now()
    with transaction.atomic():
        record, created = IdempotencyRecord.objects.get_or_create(
            key=key,
            defaults={
                "fingerprint": fingerprint,
                "status": IdempotencyRecord.STATUS_IN_PROGRESS,
                "expires_at": now + lock_ttl,
            },
        )
    if created:
        return AcquireResult(acquired=True, existing=None)
    return self._handle_existing(record, fingerprint, now=now, lock_ttl=lock_ttl)

cleanup_expired()

See :meth:drf_idempotency.backends.base.BaseBackend.cleanup_expired.

Source code in src/drf_idempotency/backends/database.py
117
118
119
120
def cleanup_expired(self) -> int:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.cleanup_expired`."""
    deleted, _ = IdempotencyRecord.objects.filter(expires_at__lte=timezone.now()).delete()
    return deleted

complete(key, *, status_code, headers, body, ttl)

See :meth:drf_idempotency.backends.base.BaseBackend.complete.

Source code in src/drf_idempotency/backends/database.py
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def complete(
    self,
    key: str,
    *,
    status_code: int,
    headers: Mapping[str, str],
    body: bytes,
    ttl: timedelta,
) -> None:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.complete`."""
    now = timezone.now()
    IdempotencyRecord.objects.filter(key=key).update(
        status=IdempotencyRecord.STATUS_COMPLETED,
        response_status_code=status_code,
        response_headers=dict(headers),
        response_body=base64.b64encode(body).decode("ascii"),
        completed_at=now,
        expires_at=now + ttl,
    )

fail(key)

See :meth:drf_idempotency.backends.base.BaseBackend.fail.

Deletes the record entirely, so the next request using this key starts fresh rather than being permanently stuck.

Source code in src/drf_idempotency/backends/database.py
 99
100
101
102
103
104
105
def fail(self, key: str) -> None:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.fail`.

    Deletes the record entirely, so the next request using this key
    starts fresh rather than being permanently stuck.
    """
    IdempotencyRecord.objects.filter(key=key).delete()

get(key)

See :meth:drf_idempotency.backends.base.BaseBackend.get.

Source code in src/drf_idempotency/backends/database.py
107
108
109
110
111
112
113
114
115
def get(self, key: str) -> StoredRecord | None:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.get`."""
    try:
        record = IdempotencyRecord.objects.get(key=key)
    except IdempotencyRecord.DoesNotExist:
        return None
    if record.expires_at <= timezone.now():
        return None
    return self._to_stored_record(record)

RedisBackend

Bases: BaseBackend

Stores idempotency records in Redis.

Parameters:

Name Type Description Default
url str | None

A redis:// connection URL. Ignored if client is given. Defaults to "redis://localhost:6379/0".

None
client Any | None

An existing redis.Redis client instance to reuse (e.g. one already configured elsewhere in your project).

None
key_prefix str

Prefix applied to every Redis key this backend writes, to avoid collisions with unrelated keys in a shared Redis instance.

_DEFAULT_KEY_PREFIX
**options Any

Accepted for interface compatibility; unused.

{}
Source code in src/drf_idempotency/backends/redis.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
class RedisBackend(BaseBackend):
    """Stores idempotency records in Redis.

    Args:
        url: A ``redis://`` connection URL. Ignored if ``client`` is
            given. Defaults to ``"redis://localhost:6379/0"``.
        client: An existing ``redis.Redis`` client instance to reuse
            (e.g. one already configured elsewhere in your project).
        key_prefix: Prefix applied to every Redis key this backend
            writes, to avoid collisions with unrelated keys in a shared
            Redis instance.
        **options: Accepted for interface compatibility; unused.
    """

    def __init__(
        self,
        *,
        url: str | None = None,
        client: Any | None = None,
        key_prefix: str = _DEFAULT_KEY_PREFIX,
        **options: Any,
    ) -> None:
        super().__init__(**options)
        self._client = (
            client
            if client is not None
            else redis_lib.Redis.from_url(url or "redis://localhost:6379/0")
        )
        self._prefix = key_prefix

    def _redis_key(self, key: str) -> str:
        return f"{self._prefix}{key}"

    def acquire_or_get(self, key: str, fingerprint: str, *, lock_ttl: timedelta) -> AcquireResult:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.acquire_or_get`."""
        redis_key = self._redis_key(key)
        payload = self._encode(
            fingerprint=fingerprint,
            status="in_progress",
            response_status_code=None,
            response_headers=None,
            response_body=None,
        )
        acquired = self._client.set(redis_key, payload, nx=True, ex=int(lock_ttl.total_seconds()))
        if acquired:
            return AcquireResult(acquired=True, existing=None)
        raw = self._client.get(redis_key)
        if raw is None:
            # The record expired between our failed SETNX and this GET -
            # vanishingly rare, but safe to simply retry once.
            return self.acquire_or_get(key, fingerprint, lock_ttl=lock_ttl)
        return AcquireResult(acquired=False, existing=self._decode(key, raw))

    def complete(
        self,
        key: str,
        *,
        status_code: int,
        headers: Mapping[str, str],
        body: bytes,
        ttl: timedelta,
    ) -> None:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.complete`."""
        redis_key = self._redis_key(key)
        existing = self._client.get(redis_key)
        fingerprint = json.loads(existing)["fingerprint"] if existing else ""
        payload = self._encode(
            fingerprint=fingerprint,
            status="completed",
            response_status_code=status_code,
            response_headers=dict(headers),
            response_body=base64.b64encode(body).decode("ascii"),
        )
        self._client.set(redis_key, payload, ex=int(ttl.total_seconds()))

    def fail(self, key: str) -> None:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.fail`."""
        self._client.delete(self._redis_key(key))

    def get(self, key: str) -> StoredRecord | None:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.get`."""
        raw = self._client.get(self._redis_key(key))
        if raw is None:
            return None
        return self._decode(key, raw)

    def cleanup_expired(self) -> int:
        """See :meth:`drf_idempotency.backends.base.BaseBackend.cleanup_expired`.

        Always returns ``0`` — Redis expires keys natively via ``EX``, so
        there is nothing for this method to do.
        """
        return 0

    def _encode(
        self,
        *,
        fingerprint: str,
        status: str,
        response_status_code: int | None,
        response_headers: Mapping[str, str] | None,
        response_body: str | None,
    ) -> str:
        return json.dumps(
            {
                "fingerprint": fingerprint,
                "status": status,
                "response_status_code": response_status_code,
                "response_headers": response_headers,
                "response_body": response_body,
                "created_at": timezone.now().isoformat(),
            }
        )

    def _decode(self, key: str, raw: bytes | str) -> StoredRecord:
        data = json.loads(raw)
        body = base64.b64decode(data["response_body"]) if data.get("response_body") else None
        return StoredRecord(
            key=key,
            fingerprint=data["fingerprint"],
            status=data["status"],
            response_status_code=data.get("response_status_code"),
            response_headers=data.get("response_headers"),
            response_body=body,
            created_at=datetime.fromisoformat(data["created_at"]),
        )

acquire_or_get(key, fingerprint, *, lock_ttl)

See :meth:drf_idempotency.backends.base.BaseBackend.acquire_or_get.

Source code in src/drf_idempotency/backends/redis.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def acquire_or_get(self, key: str, fingerprint: str, *, lock_ttl: timedelta) -> AcquireResult:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.acquire_or_get`."""
    redis_key = self._redis_key(key)
    payload = self._encode(
        fingerprint=fingerprint,
        status="in_progress",
        response_status_code=None,
        response_headers=None,
        response_body=None,
    )
    acquired = self._client.set(redis_key, payload, nx=True, ex=int(lock_ttl.total_seconds()))
    if acquired:
        return AcquireResult(acquired=True, existing=None)
    raw = self._client.get(redis_key)
    if raw is None:
        # The record expired between our failed SETNX and this GET -
        # vanishingly rare, but safe to simply retry once.
        return self.acquire_or_get(key, fingerprint, lock_ttl=lock_ttl)
    return AcquireResult(acquired=False, existing=self._decode(key, raw))

cleanup_expired()

See :meth:drf_idempotency.backends.base.BaseBackend.cleanup_expired.

Always returns 0 — Redis expires keys natively via EX, so there is nothing for this method to do.

Source code in src/drf_idempotency/backends/redis.py
119
120
121
122
123
124
125
def cleanup_expired(self) -> int:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.cleanup_expired`.

    Always returns ``0`` — Redis expires keys natively via ``EX``, so
    there is nothing for this method to do.
    """
    return 0

complete(key, *, status_code, headers, body, ttl)

See :meth:drf_idempotency.backends.base.BaseBackend.complete.

Source code in src/drf_idempotency/backends/redis.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def complete(
    self,
    key: str,
    *,
    status_code: int,
    headers: Mapping[str, str],
    body: bytes,
    ttl: timedelta,
) -> None:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.complete`."""
    redis_key = self._redis_key(key)
    existing = self._client.get(redis_key)
    fingerprint = json.loads(existing)["fingerprint"] if existing else ""
    payload = self._encode(
        fingerprint=fingerprint,
        status="completed",
        response_status_code=status_code,
        response_headers=dict(headers),
        response_body=base64.b64encode(body).decode("ascii"),
    )
    self._client.set(redis_key, payload, ex=int(ttl.total_seconds()))

fail(key)

See :meth:drf_idempotency.backends.base.BaseBackend.fail.

Source code in src/drf_idempotency/backends/redis.py
108
109
110
def fail(self, key: str) -> None:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.fail`."""
    self._client.delete(self._redis_key(key))

get(key)

See :meth:drf_idempotency.backends.base.BaseBackend.get.

Source code in src/drf_idempotency/backends/redis.py
112
113
114
115
116
117
def get(self, key: str) -> StoredRecord | None:
    """See :meth:`drf_idempotency.backends.base.BaseBackend.get`."""
    raw = self._client.get(self._redis_key(key))
    if raw is None:
        return None
    return self._decode(key, raw)

Models

IdempotencyRecord

Bases: Model

A single idempotency key's stored state and (eventually) response.

Attributes:

Name Type Description
key

The client-supplied idempotency key. Unique — this uniqueness constraint is exactly what makes concurrent lock acquisition race-safe (see docs/architecture.md).

fingerprint

A SHA-256 hex digest of the request method, path, and body, computed by :func:drf_idempotency.fingerprint.compute_fingerprint. Used to detect a key being reused with a different request.

status

One of "in_progress", "completed", or "failed".

response_status_code

The HTTP status code of the stored response, once completed.

response_headers

The stored response headers, as a JSON object.

response_body

The stored response body, base64-encoded (so binary bodies round-trip safely through a text column).

created_at

When this record was first created (lock acquired).

completed_at

When the response was stored, if it has been.

expires_at

When this record should be treated as gone. Rows past this timestamp are ignored by reads and removed by the cleanup_expired_idempotency_keys management command.

Source code in src/drf_idempotency/models.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class IdempotencyRecord(models.Model):
    """A single idempotency key's stored state and (eventually) response.

    Attributes:
        key: The client-supplied idempotency key. Unique — this uniqueness
            constraint is exactly what makes concurrent lock acquisition
            race-safe (see ``docs/architecture.md``).
        fingerprint: A SHA-256 hex digest of the request method, path, and
            body, computed by :func:`drf_idempotency.fingerprint.compute_fingerprint`.
            Used to detect a key being reused with a different request.
        status: One of ``"in_progress"``, ``"completed"``, or ``"failed"``.
        response_status_code: The HTTP status code of the stored response,
            once completed.
        response_headers: The stored response headers, as a JSON object.
        response_body: The stored response body, base64-encoded (so
            binary bodies round-trip safely through a text column).
        created_at: When this record was first created (lock acquired).
        completed_at: When the response was stored, if it has been.
        expires_at: When this record should be treated as gone. Rows past
            this timestamp are ignored by reads and removed by the
            ``cleanup_expired_idempotency_keys`` management command.
    """

    STATUS_IN_PROGRESS = "in_progress"
    STATUS_COMPLETED = "completed"
    STATUS_FAILED = "failed"
    STATUS_CHOICES = (
        (STATUS_IN_PROGRESS, "In progress"),
        (STATUS_COMPLETED, "Completed"),
        (STATUS_FAILED, "Failed"),
    )

    key = models.CharField(max_length=255, unique=True)
    fingerprint = models.CharField(max_length=64)
    status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_IN_PROGRESS)
    response_status_code = models.PositiveSmallIntegerField(null=True, blank=True)
    response_headers = models.JSONField(default=dict, blank=True)
    response_body = models.TextField(blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)
    completed_at = models.DateTimeField(null=True, blank=True)
    expires_at = models.DateTimeField(db_index=True)

    class Meta:
        app_label = "drf_idempotency"
        indexes = [models.Index(fields=["expires_at"])]

    def __str__(self) -> str:  # pragma: no cover - debugging aid only
        return f"{self.key} ({self.status})"

Exceptions

IdempotencyError

Bases: APIException

Base class for all errors raised by this package.

Source code in src/drf_idempotency/exceptions.py
15
16
17
18
class IdempotencyError(APIException):
    """Base class for all errors raised by this package."""

    default_code = "idempotency_error"

MissingIdempotencyKeyError

Bases: IdempotencyError

Raised when REQUIRE_KEY is enabled and a request using a configured method omits the idempotency key header.

Source code in src/drf_idempotency/exceptions.py
21
22
23
24
25
26
27
class MissingIdempotencyKeyError(IdempotencyError):
    """Raised when ``REQUIRE_KEY`` is enabled and a request using a
    configured method omits the idempotency key header."""

    status_code = 400
    default_code = "missing_idempotency_key"
    default_detail = "This request requires an Idempotency-Key header."

InvalidIdempotencyKeyError

Bases: IdempotencyError

Raised when a supplied idempotency key fails validation (empty, too long, or containing disallowed characters).

Parameters:

Name Type Description Default
reason str

A human-readable description of why the key is invalid.

required
Source code in src/drf_idempotency/exceptions.py
30
31
32
33
34
35
36
37
38
39
40
41
42
class InvalidIdempotencyKeyError(IdempotencyError):
    """Raised when a supplied idempotency key fails validation (empty,
    too long, or containing disallowed characters).

    Args:
        reason: A human-readable description of why the key is invalid.
    """

    status_code = 400
    default_code = "invalid_idempotency_key"

    def __init__(self, reason: str) -> None:
        super().__init__(detail=f"Invalid Idempotency-Key: {reason}", code=self.default_code)

IdempotencyKeyReuseError

Bases: IdempotencyError

Raised when an idempotency key is reused with a request whose fingerprint (method, path, and body) does not match the original request that key was first used for.

Parameters:

Name Type Description Default
key str

The reused idempotency key.

required
Source code in src/drf_idempotency/exceptions.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class IdempotencyKeyReuseError(IdempotencyError):
    """Raised when an idempotency key is reused with a request whose
    fingerprint (method, path, and body) does not match the original
    request that key was first used for.

    Args:
        key: The reused idempotency key.
    """

    status_code = 422
    default_code = "idempotency_key_reuse"

    def __init__(self, key: str) -> None:
        super().__init__(
            detail=(
                f"The Idempotency-Key {key!r} was previously used with a different "
                f"request. Use a new key for a different request body."
            ),
            code=self.default_code,
        )

ConcurrentRequestError

Bases: IdempotencyError

Raised when another request using the same idempotency key is already being processed.

Parameters:

Name Type Description Default
key str

The idempotency key currently locked by another request.

required
Source code in src/drf_idempotency/exceptions.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class ConcurrentRequestError(IdempotencyError):
    """Raised when another request using the same idempotency key is
    already being processed.

    Args:
        key: The idempotency key currently locked by another request.
    """

    status_code = 409
    default_code = "idempotency_request_in_progress"

    def __init__(self, key: str) -> None:
        super().__init__(
            detail=(
                f"A request with Idempotency-Key {key!r} is already being processed. "
                f"Retry after it completes."
            ),
            code=self.default_code,
        )

Settings

See Settings for the full list of recognized IDEMPOTENCY keys.

Management command

cleanup_expired_idempotency_keys

python manage.py cleanup_expired_idempotency_keys [--quiet]

See Deployment.