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.
- drf_pollable-0.1.0/CHANGELOG.md +76 -0
- drf_pollable-0.1.0/GUIDE.md +533 -0
- drf_pollable-0.1.0/LICENSE +21 -0
- drf_pollable-0.1.0/MANIFEST.in +21 -0
- drf_pollable-0.1.0/PKG-INFO +439 -0
- drf_pollable-0.1.0/README.md +399 -0
- drf_pollable-0.1.0/drf_pollable/__init__.py +82 -0
- drf_pollable-0.1.0/drf_pollable/apps.py +31 -0
- drf_pollable-0.1.0/drf_pollable/conf.py +154 -0
- drf_pollable-0.1.0/drf_pollable/envelope.py +52 -0
- drf_pollable-0.1.0/drf_pollable/exceptions.py +13 -0
- drf_pollable-0.1.0/drf_pollable/headers.py +101 -0
- drf_pollable-0.1.0/drf_pollable/intervals.py +76 -0
- drf_pollable-0.1.0/drf_pollable/management/__init__.py +0 -0
- drf_pollable-0.1.0/drf_pollable/management/commands/__init__.py +0 -0
- drf_pollable-0.1.0/drf_pollable/management/commands/list_polls.py +73 -0
- drf_pollable-0.1.0/drf_pollable/mixins.py +101 -0
- drf_pollable-0.1.0/drf_pollable/registration.py +311 -0
- drf_pollable-0.1.0/drf_pollable/routers.py +55 -0
- drf_pollable-0.1.0/drf_pollable/sources.py +570 -0
- drf_pollable-0.1.0/drf_pollable/state.py +173 -0
- drf_pollable-0.1.0/drf_pollable/testing.py +85 -0
- drf_pollable-0.1.0/drf_pollable/throttling.py +172 -0
- drf_pollable-0.1.0/drf_pollable/views.py +278 -0
- drf_pollable-0.1.0/drf_pollable.egg-info/PKG-INFO +439 -0
- drf_pollable-0.1.0/drf_pollable.egg-info/SOURCES.txt +61 -0
- drf_pollable-0.1.0/drf_pollable.egg-info/dependency_links.txt +1 -0
- drf_pollable-0.1.0/drf_pollable.egg-info/requires.txt +7 -0
- drf_pollable-0.1.0/drf_pollable.egg-info/top_level.txt +1 -0
- drf_pollable-0.1.0/example/README.md +55 -0
- drf_pollable-0.1.0/example/manage.py +23 -0
- drf_pollable-0.1.0/example/settings.py +51 -0
- drf_pollable-0.1.0/example/shop/__init__.py +0 -0
- drf_pollable-0.1.0/example/shop/apps.py +6 -0
- drf_pollable-0.1.0/example/shop/management/__init__.py +0 -0
- drf_pollable-0.1.0/example/shop/management/commands/__init__.py +0 -0
- drf_pollable-0.1.0/example/shop/management/commands/seed.py +21 -0
- drf_pollable-0.1.0/example/shop/migrations/0001_initial.py +33 -0
- drf_pollable-0.1.0/example/shop/migrations/__init__.py +0 -0
- drf_pollable-0.1.0/example/shop/models.py +27 -0
- drf_pollable-0.1.0/example/shop/polls.py +58 -0
- drf_pollable-0.1.0/example/urls.py +7 -0
- drf_pollable-0.1.0/pyproject.toml +79 -0
- drf_pollable-0.1.0/runtests.py +32 -0
- drf_pollable-0.1.0/setup.cfg +4 -0
- drf_pollable-0.1.0/tests/__init__.py +0 -0
- drf_pollable-0.1.0/tests/base.py +46 -0
- drf_pollable-0.1.0/tests/settings.py +60 -0
- drf_pollable-0.1.0/tests/test_extras.py +132 -0
- drf_pollable-0.1.0/tests/test_limits.py +155 -0
- drf_pollable-0.1.0/tests/test_polling.py +315 -0
- drf_pollable-0.1.0/tests/test_registration.py +392 -0
- drf_pollable-0.1.0/tests/test_sources.py +450 -0
- drf_pollable-0.1.0/tests/test_styles.py +146 -0
- drf_pollable-0.1.0/tests/test_throttling.py +200 -0
- drf_pollable-0.1.0/tests/test_units.py +510 -0
- drf_pollable-0.1.0/tests/test_view_edges.py +226 -0
- drf_pollable-0.1.0/tests/testapp/__init__.py +0 -0
- drf_pollable-0.1.0/tests/testapp/apps.py +7 -0
- drf_pollable-0.1.0/tests/testapp/models.py +62 -0
- drf_pollable-0.1.0/tests/testapp/polls.py +133 -0
- drf_pollable-0.1.0/tests/testapp/views.py +24 -0
- 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
|