drf-pollable 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. drf_pollable-0.1.0/CHANGELOG.md +76 -0
  2. drf_pollable-0.1.0/GUIDE.md +533 -0
  3. drf_pollable-0.1.0/LICENSE +21 -0
  4. drf_pollable-0.1.0/MANIFEST.in +21 -0
  5. drf_pollable-0.1.0/PKG-INFO +439 -0
  6. drf_pollable-0.1.0/README.md +399 -0
  7. drf_pollable-0.1.0/drf_pollable/__init__.py +82 -0
  8. drf_pollable-0.1.0/drf_pollable/apps.py +31 -0
  9. drf_pollable-0.1.0/drf_pollable/conf.py +154 -0
  10. drf_pollable-0.1.0/drf_pollable/envelope.py +52 -0
  11. drf_pollable-0.1.0/drf_pollable/exceptions.py +13 -0
  12. drf_pollable-0.1.0/drf_pollable/headers.py +101 -0
  13. drf_pollable-0.1.0/drf_pollable/intervals.py +76 -0
  14. drf_pollable-0.1.0/drf_pollable/management/__init__.py +0 -0
  15. drf_pollable-0.1.0/drf_pollable/management/commands/__init__.py +0 -0
  16. drf_pollable-0.1.0/drf_pollable/management/commands/list_polls.py +73 -0
  17. drf_pollable-0.1.0/drf_pollable/mixins.py +101 -0
  18. drf_pollable-0.1.0/drf_pollable/registration.py +311 -0
  19. drf_pollable-0.1.0/drf_pollable/routers.py +55 -0
  20. drf_pollable-0.1.0/drf_pollable/sources.py +570 -0
  21. drf_pollable-0.1.0/drf_pollable/state.py +173 -0
  22. drf_pollable-0.1.0/drf_pollable/testing.py +85 -0
  23. drf_pollable-0.1.0/drf_pollable/throttling.py +172 -0
  24. drf_pollable-0.1.0/drf_pollable/views.py +278 -0
  25. drf_pollable-0.1.0/drf_pollable.egg-info/PKG-INFO +439 -0
  26. drf_pollable-0.1.0/drf_pollable.egg-info/SOURCES.txt +61 -0
  27. drf_pollable-0.1.0/drf_pollable.egg-info/dependency_links.txt +1 -0
  28. drf_pollable-0.1.0/drf_pollable.egg-info/requires.txt +7 -0
  29. drf_pollable-0.1.0/drf_pollable.egg-info/top_level.txt +1 -0
  30. drf_pollable-0.1.0/example/README.md +55 -0
  31. drf_pollable-0.1.0/example/manage.py +23 -0
  32. drf_pollable-0.1.0/example/settings.py +51 -0
  33. drf_pollable-0.1.0/example/shop/__init__.py +0 -0
  34. drf_pollable-0.1.0/example/shop/apps.py +6 -0
  35. drf_pollable-0.1.0/example/shop/management/__init__.py +0 -0
  36. drf_pollable-0.1.0/example/shop/management/commands/__init__.py +0 -0
  37. drf_pollable-0.1.0/example/shop/management/commands/seed.py +21 -0
  38. drf_pollable-0.1.0/example/shop/migrations/0001_initial.py +33 -0
  39. drf_pollable-0.1.0/example/shop/migrations/__init__.py +0 -0
  40. drf_pollable-0.1.0/example/shop/models.py +27 -0
  41. drf_pollable-0.1.0/example/shop/polls.py +58 -0
  42. drf_pollable-0.1.0/example/urls.py +7 -0
  43. drf_pollable-0.1.0/pyproject.toml +79 -0
  44. drf_pollable-0.1.0/runtests.py +32 -0
  45. drf_pollable-0.1.0/setup.cfg +4 -0
  46. drf_pollable-0.1.0/tests/__init__.py +0 -0
  47. drf_pollable-0.1.0/tests/base.py +46 -0
  48. drf_pollable-0.1.0/tests/settings.py +60 -0
  49. drf_pollable-0.1.0/tests/test_extras.py +132 -0
  50. drf_pollable-0.1.0/tests/test_limits.py +155 -0
  51. drf_pollable-0.1.0/tests/test_polling.py +315 -0
  52. drf_pollable-0.1.0/tests/test_registration.py +392 -0
  53. drf_pollable-0.1.0/tests/test_sources.py +450 -0
  54. drf_pollable-0.1.0/tests/test_styles.py +146 -0
  55. drf_pollable-0.1.0/tests/test_throttling.py +200 -0
  56. drf_pollable-0.1.0/tests/test_units.py +510 -0
  57. drf_pollable-0.1.0/tests/test_view_edges.py +226 -0
  58. drf_pollable-0.1.0/tests/testapp/__init__.py +0 -0
  59. drf_pollable-0.1.0/tests/testapp/apps.py +7 -0
  60. drf_pollable-0.1.0/tests/testapp/models.py +62 -0
  61. drf_pollable-0.1.0/tests/testapp/polls.py +133 -0
  62. drf_pollable-0.1.0/tests/testapp/views.py +24 -0
  63. drf_pollable-0.1.0/tests/urls.py +16 -0
@@ -0,0 +1,76 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.1.0] — 2026-10-17
7
+
8
+ First release. Generates read-only polling ("status") endpoints for Django REST
9
+ Framework from a model field, instance, queryset, or callable.
10
+
11
+ ### Added
12
+
13
+ **Sources** — four kinds, all behind one `PollSource` abstraction:
14
+ - `FieldSource` — one field of one instance, with the choice `display` label included
15
+ - `InstanceSource` — several fields, gated behind a required `fields`/`serializer` allow-list
16
+ - `QuerySetSource` — lists, `count`/`exists` aggregates, and `?since=` delta polling with a cursor
17
+ - `CallableSource` — any function, returning a value or a `PollResult`
18
+
19
+ **Declaration styles** — five, all producing identical behaviour:
20
+ - `register_poll()` function call or decorator
21
+ - `PollEndpoint` subclass (self-registering)
22
+ - `PollingMeta` inner class on a model
23
+ - `DRF_POLLABLE["ENDPOINTS"]` in settings
24
+ - `PollingMixin` on an existing ViewSet
25
+
26
+ **Interval negotiation** — the client may always ask to poll slower, never faster:
27
+ - `min_poll_interval` floor, `suggested_poll_interval` advertised via `Retry-After`
28
+ - client-requested cadence via `?interval=`, clamped to `[min, max]`
29
+ - fixed or exponential `backoff` while the value is unchanged
30
+ - `jitter` to de-synchronise clients
31
+ - `done_when` sets `done: true` so the client stops polling
32
+
33
+ **Rate limiting**, layered on DRF throttling:
34
+ - `PollIntervalThrottle` — per-object cadence floor (`enforce_interval` → `429`)
35
+ - `PollScopedRateThrottle`, `PollBurstThrottle`, `PollSustainedThrottle`
36
+ - `GlobalPollThrottle` — per-endpoint ceiling across all callers
37
+ - `TenantRateThrottle` — per-tenant isolation via `TENANT_RESOLVER`
38
+
39
+ **Bandwidth**:
40
+ - `ETag` / `If-None-Match` → bodyless `304`
41
+ - `Last-Modified` / `If-Modified-Since`
42
+ - optional short-TTL value cache (`cache_timeout`, `cache_vary_on_user`)
43
+
44
+ **Operations**:
45
+ - two-layer configuration (per-endpoint → global `DEFAULTS` → built-ins), with
46
+ unknown options rejected loudly
47
+ - `enabled` kill switch → `503 + Retry-After`
48
+ - `Sunset` / `Deprecation` headers
49
+ - `X-Poll-Upgrade-Hint` when data changes on nearly every poll
50
+ - `manage.py list_polls` for introspection
51
+ - `drf_pollable.testing` helpers: `clear_poll_state`, `poll_until`,
52
+ `assert_poll_headers`, `assert_not_modified`
53
+
54
+ ### Fixed
55
+
56
+ - `If-Modified-Since` could answer a changed value with a stale `304`. The
57
+ `Last-Modified` timestamp now tracks when the current value was first
58
+ observed, so it moves with the value exactly as the `ETag` does, instead of
59
+ trusting an `auto_now` column that `QuerySet.update()` never fires. Because
60
+ HTTP dates have one-second resolution, a modification within the second it
61
+ was issued is reported as a full `200` rather than risked as a `304`.
62
+
63
+ ### Removed
64
+
65
+ - The undocumented `lookup_regex` option. It accepted a value, discarded it,
66
+ and emitted a converter-less URL that matched exactly as before, so a caller
67
+ restricting a lookup got no restriction. Setting it now raises, like any
68
+ other unknown option.
69
+
70
+ ### Notes
71
+
72
+ - Version tokens hash the **value**, not a timestamp field. An `auto_now` column
73
+ does not update on `QuerySet.update()`, so a timestamp-derived `ETag` would
74
+ miss real changes and serve a stale `304`.
75
+ - Instance and list sources require an explicit field allow-list so that fields
76
+ added to a model later are never exposed by accident.
@@ -0,0 +1,533 @@
1
+ # drf-pollable usage guide
2
+
3
+ This is the decision and usage guide for [drf-pollable](README.md). The README
4
+ is the short reference (install, options, examples). This file answers three
5
+ questions:
6
+
7
+ 1. Should this status check be a poll at all?
8
+ 2. If yes, how do you declare it so it stays safe as the model and the traffic grow?
9
+ 3. What must the client do for the server-side machinery to actually help?
10
+
11
+ drf-pollable generates a **read-only HTTP endpoint** for "what is the current
12
+ value of this thing?". It is not a messaging system, a job queue, or a
13
+ replacement for a ViewSet you already use for create/update/delete.
14
+
15
+ ---
16
+
17
+ ## 1. Use it when
18
+
19
+ Use a polling endpoint when all of the following are true:
20
+
21
+ - A person or a service needs the **current value** of one field, a few fields, a count, or a short list.
22
+ - Freshness of **a few seconds** is enough. The data changes on a human or job timescale: an order moves from `pending` to `shipped`, a report finishes, a badge count ticks up.
23
+ - The caller is a browser, a mobile app, or another service that can make ordinary HTTP requests. It does not have to be reachable from the public internet.
24
+ - You already run Django REST Framework and want the poll to use the **same authentication, permissions, and throttles** as the rest of the API.
25
+ - The deployment is WSGI, or you do not want a long-lived connection per client (ASGI, a channel layer, sticky sessions, heartbeats).
26
+
27
+ Typical fits:
28
+
29
+ | Situation | What you poll | Why a poll is enough |
30
+ |---|---|---|
31
+ | Order, shipment, payout, or ticket status | One choice field, with `done_when` set to the terminal values | The value changes a handful of times, then the client stops |
32
+ | Import, export, encoding, or payment progress | A field plus `progress_field` / `eta_field`, or a `PollResult` | The UI needs a bar, not a stream of events |
33
+ | Unread badge | `aggregate="count"` on a scoped queryset | One integer; a list would be wasted work |
34
+ | Notification inbox | A list with `since_field` | The client fetches the delta since its cursor |
35
+ | "Is the pipeline still running?" | A function that returns a `PollResult` | The status is computed, not a single column |
36
+
37
+ A concrete declaration for the headline case:
38
+
39
+ ```python
40
+ # shop/polls.py — auto-discovered, like admin.py
41
+ from drf_pollable import register_poll
42
+
43
+ register_poll(
44
+ "order-status",
45
+ source="shop.Order.status",
46
+ min_poll_interval=5,
47
+ done_when={"delivered", "cancelled"},
48
+ permission_classes=["rest_framework.permissions.IsAuthenticated"],
49
+ scope=lambda queryset, request: queryset.filter(owner=request.user),
50
+ )
51
+ ```
52
+
53
+ `GET /poll/order-status/42/` then returns the value, whether the client should
54
+ stop, and when to come back. Unchanged polls answer `304` with an empty body.
55
+
56
+ ---
57
+
58
+ ## 2. Use something else when
59
+
60
+ Pick another tool when the constraint below is the real requirement. drf-pollable
61
+ will not meet it, and wrapping it to try will cost more than using the right
62
+ mechanism.
63
+
64
+ | You need | Use this instead | Why a status poll is the wrong shape |
65
+ |---|---|---|
66
+ | Sub-second updates, or the server must speak first | WebSockets (Django Channels, or a hosted realtime service) | A poll can only be as fresh as its interval, and the client has to ask |
67
+ | Two-way interaction: chat, cursors, presence, collaborative editing, live trading | WebSockets | Each side sends on its own clock; a poll is a question, not a conversation |
68
+ | The app is backgrounded and must wake up | APNs / FCM / web push | A poll only runs while the client is awake and asking |
69
+ | Server-to-server delivery to an endpoint you do not control | Webhooks | The other system is the server. It cannot poll you, and you cannot rely on it polling itself |
70
+ | A worker should block until a job exists | A queue (or long-polling, knowingly) | Polling a Django view from a tight loop still occupies your scheduler; a queue is the waiting primitive |
71
+ | A full resource API: list, create, update, delete | A DRF `ViewSet` | A poll is a status lens on data you already expose. Add `PollingMixin` only for the status action |
72
+ | One request must read many unrelated objects | Your own aggregate endpoint | A batch poll is not shipped. N polls means N requests |
73
+ | The value is a large document that changes continuously | A stream, or a delta protocol you design | Conditional requests save an unchanged body; they do not make a firehose cheap |
74
+ | OpenAPI must describe the envelope and the `304` today | Hand-written schema, for now | A drf-spectacular extension is planned, not included |
75
+ | You need the client to learn about a change with no request at all | Push or a socket | Polling always spends a request to discover that nothing changed |
76
+
77
+ If a poll's value changes on almost every request, the server can set
78
+ `upgrade_hint_after`. After that many consecutive *changes* it sends
79
+ `X-Poll-Upgrade-Hint`. Treat that header as an operational signal to move that
80
+ endpoint to a socket or SSE, not as something the client has to implement.
81
+
82
+ ---
83
+
84
+ ## 3. How it compares
85
+
86
+ Polling's weaknesses are well known: the client waits up to one interval to
87
+ notice a change, and idle clients still send requests. drf-pollable spends its
88
+ complexity on those two costs (`Retry-After` with backoff, `304`, `done`) and
89
+ leaves the transport as plain HTTP. The comparison below is about a **status
90
+ check**, not about every realtime problem.
91
+
92
+ ### Against a hand-written DRF action
93
+
94
+ A `GET /orders/{id}/status/` that you write yourself is the closest alternative,
95
+ and it is the right alternative when the response is not a status poll (a
96
+ one-shot report, a signed download, a command).
97
+
98
+ For an actual poll, the hand-written action tends to grow the same machinery
99
+ one endpoint at a time, and each copy drifts:
100
+
101
+ - The `ETag` is derived from `updated_at`. `QuerySet.update()` does not touch
102
+ `auto_now` columns, so clients receive a stale `304` and keep showing the old
103
+ status. drf-pollable hashes the **value**.
104
+ - There is no `Retry-After`, so every client invents an interval. One buggy
105
+ loop polls at 100ms and you have no floor.
106
+ - "Stop when delivered" is a convention in the frontend repo, not a field in
107
+ the response.
108
+ - Object permissions, owner scoping, and a per-object rate limit are easy to
109
+ forget on the second endpoint.
110
+
111
+ drf-pollable gives you one declaration and the same contract on every endpoint:
112
+ envelope, `ETag`, `Retry-After`, `done`, throttles, and the permission check.
113
+
114
+ ### Against WebSockets
115
+
116
+ | | WebSockets | drf-pollable |
117
+ |---|---|---|
118
+ | Freshness | As fast as the network, once connected | Bounded by the interval you advertise (seconds, by design) |
119
+ | Direction | Both sides send | Client asks, server answers |
120
+ | Connection | One long-lived socket per client | One short request, then the worker is free |
121
+ | Infrastructure | ASGI, a channel layer, reconnect, heartbeats, auth on upgrade | The WSGI/DRF stack you already run |
122
+ | Proxies and firewalls | Upgrade requests are commonly blocked or buffered | Ordinary HTTPS |
123
+ | Idle cost | The socket and the pub/sub subscription stay up | `304` plus a growing `Retry-After` while the value is stable |
124
+ | Auth | A second scheme, unless you carefully reuse the session on upgrade | The same DRF authentication and permission classes |
125
+
126
+ Choose sockets when the product is the stream. Choose a poll when the product
127
+ is a value that is usually the same as last time.
128
+
129
+ ### Against Server-Sent Events
130
+
131
+ SSE is still one long-lived response per client. On a synchronous WSGI worker
132
+ that pins a process for the life of the stream. Browsers also cap HTTP/1.1
133
+ connections per host (commonly six), so a few open streams starve the rest of
134
+ the origin. Many proxies buffer `text/event-stream` until the response ends,
135
+ which defeats the stream.
136
+
137
+ A poll uses a normal response, ends, and releases the worker. The cost is the
138
+ interval of latency and the request rate, both of which you cap.
139
+
140
+ ### Against long-polling
141
+
142
+ Long-polling holds the request open until the value changes or a timeout fires.
143
+ That is still a poll, with a worker occupied for the whole wait. Under load
144
+ this is how sync worker pools get exhausted. drf-pollable answers immediately
145
+ and tells the client when to come back. An opt-in long-poll mode is
146
+ deliberately not included.
147
+
148
+ ### Against webhooks
149
+
150
+ A webhook is the right call when **you** are notifying **someone else's
151
+ server**: payment captured, shipment handed to the carrier, a partner's
152
+ endpoint. The receiver must be publicly reachable, and you must handle retries,
153
+ signatures, and "they were down, so they missed it".
154
+
155
+ A poll is the right call when the consumer is a browser or a phone. The client
156
+ asks for the current value, so a missed interval heals on the next request.
157
+ There is no unsigned POST to verify and no hole while the tab was closed: the
158
+ next poll returns the value as it is now.
159
+
160
+ They compose. A webhook can update your model; the user's browser polls that
161
+ model. drf-pollable is the second half.
162
+
163
+ ### Against mobile and web push
164
+
165
+ Push is how you get attention when the app is not running. Delivery is not
166
+ ordered, not guaranteed, and the payload is often a hint ("order updated")
167
+ rather than the authoritative value. After the user opens the app you still
168
+ need a request that returns the current status. That request can be a
169
+ drf-pollable endpoint. Push does not replace it.
170
+
171
+ ### Against "just poll the existing list endpoint"
172
+
173
+ `GET /api/orders/42/` on a timer works until it does not:
174
+
175
+ - The body is the whole resource, so an unchanged poll is not cheap.
176
+ - Nothing tells the client the minimum safe interval, or that the status is terminal.
177
+ - Your list throttles are shaped for humans clicking, not for a loop.
178
+
179
+ Point a poll at the field you actually care about. Keep the ViewSet for
180
+ everything else. `PollingMixin` adds `GET /orders/{pk}/poll/` on that ViewSet
181
+ when you want the poll next to the resource URL.
182
+
183
+ ---
184
+
185
+ ## 4. Declare the endpoint
186
+
187
+ Any installed app may contain a `polls.py`. `DrfPollableConfig.ready()` loads
188
+ them, then model `PollingMeta`, then `DRF_POLLABLE["ENDPOINTS"]`. Later sources
189
+ win when the name collides, so settings can override a declaration shipped in
190
+ code. Mount the routes from the **root** URLconf, after apps are ready:
191
+
192
+ ```python
193
+ # urls.py
194
+ from django.urls import path
195
+ from drf_pollable import poll_urls
196
+
197
+ urlpatterns = [
198
+ path("api/", poll_urls()),
199
+ ]
200
+ ```
201
+
202
+ That serves `GET /api/poll/<name>/<pk>/` for a single object and
203
+ `GET /api/poll/<name>/` for a list or aggregate. Only `GET`, `HEAD`, and
204
+ `OPTIONS` are accepted.
205
+
206
+ Five declaration styles produce the same runtime behaviour. Pick by where you
207
+ want the declaration to live:
208
+
209
+ | Style | Write it when |
210
+ |---|---|
211
+ | `register_poll(...)` in `polls.py` | This is the default. The endpoint is configuration, not a class. |
212
+ | `@register_poll` or a `PollEndpoint` subclass | The endpoint has enough options that a class body is easier to read, or several endpoints share an `abstract = True` base. |
213
+ | `PollingMeta` on the model | The poll is part of the model's contract and you want it beside the field. |
214
+ | `DRF_POLLABLE["ENDPOINTS"]` | Ops or a central settings module must override an endpoint without editing the app. A `scope` or other callable has to be the callable itself; a dotted path is not imported for it. Thresholds and source strings are the settings that belong here. |
215
+ | `PollingMixin` on a ViewSet | You already have the route and the permissions, and you only want `GET /{pk}/poll/`. These endpoints are **not** registered globally, so `list_polls` does not show them. |
216
+
217
+ Inspect what the process actually registered:
218
+
219
+ ```console
220
+ python manage.py list_polls
221
+ python manage.py list_polls --name order-status --verbose
222
+ ```
223
+
224
+ ---
225
+
226
+ ## 5. Choose the source
227
+
228
+ Every style reduces to one of four sources. Pick the smallest one that answers
229
+ the question the screen is asking.
230
+
231
+ ### One field
232
+
233
+ ```python
234
+ register_poll("order-status", source="shop.Order.status", done_when={"delivered"})
235
+ ```
236
+
237
+ Use this for a choice, a boolean, or a progress integer. Choice fields include
238
+ `display` (the human label) so the client does not copy `choices` into the
239
+ frontend. A property on the model is allowed; a typo in the field name fails at
240
+ first use with `PollableConfigurationError`.
241
+
242
+ `done_when` accepts a single value, a collection of terminal values, or a
243
+ callable. A string is compared for equality. It is not treated as a set of
244
+ characters: `done_when="delivered"` does not match `"d"`.
245
+
246
+ ### Several fields of one row
247
+
248
+ ```python
249
+ register_poll(
250
+ "order-detail",
251
+ source="shop.Order",
252
+ fields=["status", "progress"],
253
+ progress_field="progress",
254
+ eta_field="eta_seconds",
255
+ )
256
+ ```
257
+
258
+ `fields` or `serializer` is required. There is no "all columns" default,
259
+ because a column added next month would otherwise become part of a public poll.
260
+ `progress` and `eta_seconds` are copied onto the envelope when those fields
261
+ hold numbers, including `0`.
262
+
263
+ ### A list, a count, or a delta
264
+
265
+ ```python
266
+ register_poll(
267
+ "unread-count",
268
+ source="shop.Notification",
269
+ as_list=True,
270
+ aggregate="count",
271
+ scope=lambda queryset, request: queryset.filter(owner=request.user, read=False),
272
+ )
273
+
274
+ register_poll(
275
+ "notifications",
276
+ source="shop.Notification",
277
+ as_list=True,
278
+ fields=["id", "message"],
279
+ since_field="created_at",
280
+ order_by=["created_at"],
281
+ limit=50,
282
+ )
283
+ ```
284
+
285
+ - `aggregate` is `"count"`, `"exists"`, or a callable. The URL has **no** object id. Use this when the screen wants a scalar.
286
+ - A list also requires `fields` or `serializer`. `count` is included in the envelope.
287
+ - `since_field` adds a `cursor`. The client sends it back as `?since=<iso8601>` and receives rows strictly newer than that timestamp. A cursor that does not parse is ignored, and the full window is returned.
288
+ - `scope` is how you keep one user's rows out of another user's poll. It runs on every read, including a cache hit.
289
+ - `limit` caps the page. Combine it with `order_by` so the page is stable.
290
+
291
+ ### A function
292
+
293
+ ```python
294
+ from drf_pollable import PollResult, register_poll
295
+
296
+
297
+ def pipeline_status(request, **kwargs):
298
+ job = find_job(kwargs["pk"])
299
+ return PollResult(
300
+ value=job.state,
301
+ progress=job.percent,
302
+ eta_seconds=job.eta,
303
+ done=job.state in {"succeeded", "failed"},
304
+ )
305
+
306
+
307
+ register_poll("pipeline", source=pipeline_status, requires_object=True, min_poll_interval=2)
308
+ ```
309
+
310
+ Use a callable when the value is not a column: a third-party API, a cache key,
311
+ a state machine spread across tables. Return a plain value, or a `PollResult`
312
+ when you already know `done`, `progress`, or `eta_seconds`. Set
313
+ `requires_object=True` if the URL should carry the lookup (default is a URL
314
+ with no object id).
315
+
316
+ ---
317
+
318
+ ## 6. The contract the client follows
319
+
320
+ The server can only suggest. The client has to honour the suggestion, or the
321
+ interval floor, the `304`, and `done` do nothing.
322
+
323
+ ### Response
324
+
325
+ `200` body, when `envelope` is left on (the default):
326
+
327
+ ```json
328
+ {
329
+ "value": "shipped",
330
+ "display": "Shipped",
331
+ "version": "a844bf4f973c",
332
+ "done": false,
333
+ "next_poll_in": 5,
334
+ "polled_at": "2026-10-11T16:02:03.114+00:00"
335
+ }
336
+ ```
337
+
338
+ `display`, `progress`, `eta_seconds`, `count`, and `cursor` appear only when
339
+ the source has them. `envelope=False` returns the bare value; the metadata
340
+ stays in the headers.
341
+
342
+ Headers on `200` and `304`:
343
+
344
+ | Header | Meaning |
345
+ |---|---|
346
+ | `ETag` | Strong tag for this **value**. Send it back as `If-None-Match`. |
347
+ | `Retry-After` | Seconds to wait before the next poll. Same number as `next_poll_in`. |
348
+ | `Cache-Control` | `no-cache, private`. A cache may revalidate; it may not reuse a stale body. |
349
+ | `Last-Modified` | Present when the model has a timestamp field such as `updated_at`. Secondary to the ETag. |
350
+
351
+ `304` has an empty body. Keep the previous `value` and still wait `Retry-After`.
352
+ If both `If-None-Match` and `If-Modified-Since` are sent, `If-None-Match` wins,
353
+ including when it does **not** match.
354
+
355
+ ### Status codes
356
+
357
+ | Code | What the client does |
358
+ |---|---|
359
+ | `200` | Replace the stored value. Stop if `done` is true. |
360
+ | `304` | Keep the stored value. Wait `Retry-After`. |
361
+ | `401` / `403` | Stop. The caller is not allowed to see this row. |
362
+ | `404` | Stop, or treat the object as gone. Scoped querysets use `404` when the row is not in the caller's set. |
363
+ | `429` | Wait `Retry-After`, then try again. The client polled faster than the floor or the rate limit. |
364
+ | `503` | The endpoint is disabled (`enabled=False`). Wait `Retry-After`. |
365
+ | `405` | The client used a method other than `GET` or `HEAD`. |
366
+
367
+ ### Loop
368
+
369
+ ```javascript
370
+ async function watchStatus(url, { signal, onUpdate } = {}) {
371
+ let etag = null;
372
+ let waitSeconds = 5;
373
+
374
+ while (!signal?.aborted) {
375
+ const headers = {};
376
+ if (etag) headers["If-None-Match"] = etag;
377
+
378
+ const response = await fetch(url, { headers, signal });
379
+ const retry = Number(response.headers.get("Retry-After"));
380
+ if (Number.isFinite(retry) && retry >= 1) waitSeconds = retry;
381
+ etag = response.headers.get("ETag") || etag;
382
+
383
+ if (response.status === 200) {
384
+ const body = await response.json();
385
+ onUpdate(body);
386
+ if (body.done) return body;
387
+ } else if (response.status === 304 || response.status === 429 || response.status === 503) {
388
+ // 304 keeps the last body; 429 and 503 just delay the next attempt.
389
+ } else {
390
+ throw new Error(`poll failed: ${response.status}`);
391
+ }
392
+
393
+ await delay(waitSeconds * 1000, signal);
394
+ }
395
+ }
396
+ ```
397
+
398
+ Rules worth enforcing in review:
399
+
400
+ - Wait the server's interval. Do not hard-code `1000` and ignore `Retry-After`.
401
+ - Send the last `ETag` on every poll after the first.
402
+ - Stop on `done: true`. A terminal order that is still polled is a bug in the client.
403
+ - The client may slow itself down (`?interval=30` asks for a 30s cadence, and the server accepts it when 30 is between the floor and the ceiling). The server will not let the client go faster than `min_poll_interval`.
404
+ - Hide the tab, lose focus, or drop battery: raise `?interval=`. That is the client's job. The server does not know the tab is in the background.
405
+ - `HEAD` returns the same headers as `GET` when the client only needs to revalidate.
406
+
407
+ ---
408
+
409
+ ## 7. Set the interval on purpose
410
+
411
+ The client owns the real cadence. The server owns the floor and the
412
+ suggestion. Defaults are deliberately lenient: the floor is advertised through
413
+ `Retry-After` and is **not** enforced until you set `enforce_interval=True`.
414
+
415
+ | Knob | Set it |
416
+ |---|---|
417
+ | `min_poll_interval` | The fastest poll you are willing to serve for this data. A status that changes a few times an hour does not need a 1s floor. |
418
+ | `suggested_poll_interval` | What you want a well-behaved client to do at the start. Falls back to the floor. |
419
+ | `max_poll_interval` | The slowest you will agree to, and the cap for backoff. |
420
+ | `backoff="exponential"` | While the value is unchanged. Idle clients should get quieter: 5s, 10s, 20s, up to the cap. A change resets the streak. |
421
+ | `jitter` | When many clients start together (a page load, a push that opens the app). `0.1` spreads `Retry-After` by ±10% so they do not reconverge on the same second. |
422
+ | `enforce_interval=True` | On any endpoint a broken client could hammer. Too-soon polls return `429` with `Retry-After`. Enforcement is per caller **and** per object: hammering order 1 does not block order 2, and it does not block a different user. |
423
+ | `done_when` | Whenever the value has a real terminal state. This is the only way the request rate goes to zero. |
424
+
425
+ Backoff and the last-poll timestamp live in the Django cache, per client. A
426
+ cache flush makes the next poll look like the first one, which is safe: the
427
+ client is allowed through, and the streak starts over.
428
+
429
+ `X-Forwarded-For` is treated as the client address for anonymous callers, using
430
+ the left-most hop. Put the app behind a proxy that overwrites that header, or
431
+ anonymous users can share (or spoof) a bucket. Authenticated users are keyed by
432
+ user id, which is what you want.
433
+
434
+ ---
435
+
436
+ ## 8. Permissions, scope, and cache
437
+
438
+ ### Who can see the row
439
+
440
+ Reuse DRF. Two different mechanisms, used together:
441
+
442
+ - `permission_classes` decides whether this caller may use the endpoint, and
443
+ object-permission classes (`has_object_permission`) run against the real
444
+ model instance.
445
+ - `scope` filters the queryset. A row outside the filter is `404`, which avoids
446
+ confirming that the object exists.
447
+
448
+ ```python
449
+ register_poll(
450
+ "order-status",
451
+ source="shop.Order.status",
452
+ permission_classes=["rest_framework.permissions.IsAuthenticated"],
453
+ scope=lambda queryset, request: queryset.filter(owner=request.user),
454
+ )
455
+ ```
456
+
457
+ On a ViewSet, put object permissions on the viewset itself. `PollingMixin`
458
+ uses those classes; it does not invent a second permission stack.
459
+
460
+ ### Caching the value
461
+
462
+ `cache_timeout=0` (the default) hits the database on every poll. Set a timeout
463
+ only when the same value is requested often inside that window and you can
464
+ tolerate the value being stale for that long.
465
+
466
+ - `cache_vary_on_user=True` (the default) keeps a separate entry per caller.
467
+ Leave it on whenever two callers can see different values.
468
+ - `cache_vary_on_user=False` is for data that is identical for every caller
469
+ (a global feature flag, a public incident banner). One user's poll must not
470
+ be servable to another user.
471
+ - When the endpoint has a `scope` or any permission class other than
472
+ `AllowAny`, a cache hit still re-reads the row and re-checks access. Losing
473
+ ownership takes effect on the next poll even though the cached **value** is
474
+ reused. The timeout does not extend a permission.
475
+
476
+ ---
477
+
478
+ ## 9. Rate limits
479
+
480
+ Interval enforcement and DRF rate limits answer different questions. Use both
481
+ when the endpoint is public or shared by tenants.
482
+
483
+ | Layer | Stops |
484
+ |---|---|
485
+ | `enforce_interval=True` | One caller polling **one object** faster than the floor |
486
+ | `throttle_scope` | One caller spending the endpoint's budget (`"30/min"`) across objects |
487
+ | `PollBurstThrottle` + `PollSustainedThrottle` | A short spike and a long-run ceiling. Set `poll_burst` and `poll_sustained` in `DEFAULT_THROTTLE_RATES` |
488
+ | `GlobalPollThrottle` | Every caller combined, to protect the database. Set `poll_global` |
489
+ | `TenantRateThrottle` | One tenant starving the others. Set `DRF_POLLABLE["TENANT_RESOLVER"]` to a dotted path, and set `poll_tenant` |
490
+
491
+ A missing rate for `poll_burst`, `poll_sustained`, `poll_global`, or
492
+ `poll_tenant` raises `ImproperlyConfigured` when that throttle class is
493
+ constructed. Set the rate, or set it to `None` to leave the class installed
494
+ and inactive. Every `429` includes `Retry-After`.
495
+
496
+ These sit on top of DRF's throttle classes. An API gateway or nginx
497
+ `limit_req` in front is compatible; keep one of the layers above as the
498
+ backstop that understands per-object cadence.
499
+
500
+ ---
501
+
502
+ ## 10. Before you ship an endpoint
503
+
504
+ - [ ] The source is the smallest one that answers the screen: field, then instance, then aggregate, then list, then callable.
505
+ - [ ] Instance and list polls pass `fields` or `serializer`. No unbounded column dump.
506
+ - [ ] `scope` and object permissions match the ViewSet that already exposes this model.
507
+ - [ ] `done_when` is set if a human would stop refreshing.
508
+ - [ ] `min_poll_interval` is a number you can afford at the peak client count, not a copy of the default.
509
+ - [ ] Public or multi-tenant endpoints set `enforce_interval=True` and a scope rate.
510
+ - [ ] `cache_vary_on_user` stays `True` unless you have written down why every caller sees the same value.
511
+ - [ ] The client sends `If-None-Match`, waits `Retry-After`, and stops on `done`.
512
+ - [ ] `python manage.py list_polls --verbose` shows the floor, backoff, and throttle you think you configured.
513
+ - [ ] A test calls `clear_poll_state()` so a previous test's backoff does not leak. `poll_until` and `assert_poll_headers` cover the loop and the headers.
514
+
515
+ ```python
516
+ from drf_pollable.testing import assert_poll_headers, clear_poll_state, poll_until
517
+
518
+
519
+ def test_order_reaches_delivered(client, order):
520
+ clear_poll_state()
521
+ url = f"/poll/order-status/{order.pk}/"
522
+ assert_poll_headers(client.get(url))
523
+
524
+ def deliver(attempt):
525
+ if attempt == 1:
526
+ order.__class__.objects.filter(pk=order.pk).update(status="delivered")
527
+
528
+ response = poll_until(client, url, lambda body: body["done"], advance=deliver)
529
+ assert response.json()["value"] == "delivered"
530
+ ```
531
+
532
+ The option table, throttle snippets, and the five declaration examples live in
533
+ the [README](README.md).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdul Wajid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include GUIDE.md
5
+ include pyproject.toml
6
+ include runtests.py
7
+
8
+ # setuptools auto-includes tests/test_*.py but not the settings module, the
9
+ # URLconf, or the test app they import -- which ships a tests/ tree that
10
+ # cannot run. Graft the whole thing so a build from sdist is verifiable.
11
+ graft tests
12
+ graft example
13
+
14
+ prune .github
15
+ prune .idea
16
+ prune .ruff_cache
17
+ prune .venv
18
+ global-exclude __pycache__/*
19
+ global-exclude *.py[cod]
20
+ global-exclude *.sqlite3
21
+ global-exclude .DS_Store