Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- The maintenance page now waits for the site to come back on its own. It
polls a small status endpoint in the background and, the moment the site is
unblocked, sends the visitor to the page they originally asked for. A
deployment window is three states, not one — the old server still serving,
nothing serving while the container is replaced, the new server up — and
the page now tells them apart and says which one it is looking at.
- `DJANGO_COUNTDOWN_STATUS_PATH` (default `/__countdown_status__/`) — the
path where `CountdownBlockingMiddleware` answers that poll. It is matched
before anything else in `process_request`, so it needs no `URLconf` entry
and answers even while the rest of the site is blocked. The response is
always HTTP 200, with the state in the body behind a
`"service": "django-countdown"` marker: a reverse proxy with no upstream
answers 5xx on its own, so a status endpoint using those codes could not be
told apart from the proxy speaking for it.
- `DJANGO_COUNTDOWN_POLL_INTERVAL` (default `10` seconds) — how often the
page checks, scheduled with ±20 % of jitter so the end of a window does not
wake every waiting tab into a stampede against a server that has just
started. Only one check is ever outstanding, so switching away from the tab
and back while a check is in flight cannot leave two polling loops running
in parallel. `0` turns the polling off.
- The destination of that return trip is validated against the site's own
origin, on the server and again in the browser before navigating. A path
beginning with `//` — or with a backslash, which browsers read as a slash —
resolves as an address of its own; unchecked, the maintenance page would be
an unusually effective open redirect, since it is a page visitors are told
to trust and wait on. Django's development server and gunicorn normalise
such paths away before the request arrives, uWSGI does not, and a library
cannot know which one it runs under.
- `blocked` in that response has three values, not two. `null` means the
state could not be read — a worker that has started but cannot reach the
database yet does not know whether the site is blocked. Only an explicit
`false` sends the visitor back in; reporting `false` for an unreadable
state would put them on exactly the error page this feature exists to
avoid.
- `blocked_status_line` and `blocked_status_class` template blocks for the
new status line, and `countdown_status_path` / `countdown_poll_interval` /
`countdown_return_url` in the blocked-page context.

### Changed
- The maintenance page no longer reloads itself blindly — neither three
seconds after the timer expires nor every 30 seconds during an indefinite
window. Both reloads assumed the site would be back by the time they fired;
when it was not, they dropped the visitor on the proxy's error page, where
no script was left to try again and the only way back was a manual refresh.
The timer now only reports, and navigation happens solely on a confirmed
answer from the server.
- When the planned end passes and the site is still down, the timer's label
switches from "Estimated end of maintenance in:" to "Planned end exceeded
by:" and counts the overrun, instead of announcing "Maintenance finished!"
for something that plainly has not finished.
- Each poll feeds the server's current `maintenance_until` back into the
timer, so extending a running window with `extend_countdown` corrects pages
that are already open, without a reload.
- The always-open prefixes (`/admin/`, `/static/`, `/media/`) are now matched
against the path without the mount prefix. An application served under
`/tenant` used to lose all three during a window — including the admin
login page, the one door left for lifting the block.
- `DJANGO_COUNTDOWN_POLL_INTERVAL` accepts a string, since settings are often
read from the environment, and falls back to the default with a warning
when the value is not a number at all. It is read while rendering the
blocked page, outside the middleware's fail-open guard, so raising there
would answer every visitor with a 500 for the length of the window.
- The blocking decision moved into `get_blocking_countdown()`, shared by the
middleware and the status endpoint. The page a browser is shown and the
answer it polls for now come from one function and cannot disagree.

## [0.3.1] — 2026-08-07

A metadata-only release: no source changes, just the declared support for
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ while leaving operators unblocked so they can actually finish the work.
- **Maintenance window** — optional `maintenance_until` lets you set a target
end-time; a second banner appears for superusers and the blocked page shows
a live countdown to recovery.
- **Self-healing wait** — the blocked page polls a status endpoint in the
background and sends visitors back to the page they wanted the moment the
site returns. It tells "still down for maintenance" apart from "nothing is
answering while the container restarts", so a timer that runs out mid-deploy
no longer strands anyone on the proxy's error page.
- **Per-Site configuration** — uses Django's `sites` framework, so each
domain in a multi-tenant setup has its own independent countdown.
- **A command per verb** — `start_countdown` schedules a window,
Expand Down
9 changes: 5 additions & 4 deletions docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ Concretely:
timer and the estimated duration of the upcoming maintenance.
2. **During the window** — anonymous and non-superuser visitors get the
maintenance page with `HTTP 503`; the page shows its own timer counting
down to reopening and reloads itself when it hits zero. Superusers browse
normally and see a subdued "system under maintenance" banner instead.
down to reopening, and takes them back the moment the site returns.
Superusers browse normally and see a subdued "system under maintenance"
banner instead.
3. **After `maintenance_until`** — the middleware stops blocking, and the
banner disappears for everyone. The database row stays behind until you
delete it.
Expand All @@ -88,8 +89,8 @@ $ ./manage.py start_countdown --banner +1m --service indefinite --noinput
```

Nothing reopens the site on its own in this mode. The maintenance page tells
visitors there is no scheduled end and reloads every 30 seconds, so the site
comes back for them as soon as you unblock it.
visitors there is no scheduled end and keeps checking the server in the
background, so it takes them back within seconds of you unblocking it.

## Reopen the site

Expand Down
107 changes: 103 additions & 4 deletions docs/guide/blocked-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,107 @@ flowchart LR
| Message | `countdown.message` |
| Description | `countdown.long_description`, run through `linebreaks`, omitted when empty |
| Timer | Counts down to `countdown.maintenance_until` |
| Status line | What the background poll last learned about the server |
| Footer | Apology paragraph plus `site.name` |

With no `maintenance_until`, the timer block is replaced by "Maintenance has
no scheduled end. The site will become available once an administrator
unblocks it."

The page also reloads itself — after three seconds once the timer expires,
or every 30 seconds in indefinite mode — so visitors get back in without
having to keep hitting refresh.
The page also brings visitors back on its own, without them having to keep
hitting refresh — that is the next section.

## Waiting for the site to come back

A maintenance window is not one state, it is three, and a visitor sitting on
this page passes through all of them during an ordinary deployment:

1. The old server is still running and still blocking.
2. The container is being replaced. Nothing answers; the proxy in front
returns 502 or 504 out of its own pocket.
3. The new server is up and the countdown is gone.

So the page polls a small endpoint in the background — see
[`DJANGO_COUNTDOWN_STATUS_PATH`](../reference/settings.md#django_countdown_status_path)
— and reads the answer as follows:

| What comes back | What it means | What the visitor sees |
|---|---|---|
| `200` + `"blocked": true` | Server is up, window is still on | "Checking whether the server is back…" |
| `200` + `"blocked": true`, planned end already passed | The window is running over | "Maintenance is running longer than planned…" |
| `200` + `"blocked": null` | The server is up but cannot read its own state | "The server is restarting — please wait…" |
| `5xx`, a timeout, a refused connection, or JSON that isn't ours | Nothing is answering — mid-deploy | "The server is restarting — please wait…" |
| `200` + `"blocked": false` | There is a site to go back to | "The system is available again…", then the page they originally asked for |

`blocked` has three values, not two. A worker that has started but cannot
reach the database yet does not know whether the site is blocked, and says
so. Only an explicit `false` sends the visitor back in — a `null` read as
"not blocked" would be the one lie that matters here, since it puts them on
the error page this whole mechanism exists to avoid.

The endpoint answers **HTTP 200 in every case**, which looks odd for a thing
reporting an outage and is the entire point: a reverse proxy with no upstream
answers `502`/`503` on its own, so a status endpoint that used those codes
would be indistinguishable from the proxy speaking for it. The state travels
in the body instead, behind a `"service": "django-countdown"` marker that
tells a real answer from a captive portal or a cached error page.

```json title="GET /__countdown_status__/"
{
"service": "django-countdown",
"blocked": true,
"maintenance_until": "2026-08-25T09:36:21.509112+00:00"
}
```

### The clock reports, the poll decides

The timer on the page is a display, nothing more. It runs on the visitor's
own clock against a timestamp baked into the HTML, so it knows the plan, not
the reality — it happily reaches zero while the new container is still
starting up. Navigating on its word alone would drop the visitor on the
proxy's error page, where no script is left to try again and the only way
back is a manual refresh.

So when the planned end passes and the site is still down, nothing dramatic
happens: the label flips to "Planned end exceeded by:" and starts counting
the overrun, the status line says the server is restarting, and the page
keeps waiting. Only a confirmed `"blocked": false` navigates.

Each poll also carries the current `maintenance_until` back into the timer.
Extend a running window with
[`extend_countdown`](managing-a-countdown.md) and pages that are already open
correct themselves within one interval, without a reload.

Two smaller details, both deliberate:

- Polling **pauses while the tab is hidden** and fires immediately when the
visitor comes back, so a forgotten tab costs nothing. Only one check is ever
outstanding: a tab brought back while a check is still on its way waits for
that one rather than starting a second, which would otherwise leave two
polling loops running in parallel — and then four, and then eight.
- The return trip uses `location.replace()`, so the maintenance page does not
land in the visitor's history, and a page that was rendered in response to
a `POST` is not resubmitted.
- The whole mechanism needs `fetch` and `AbortController`. A browser
without them gets the page with a working clock and no polling — the
populations involved are vanishing, and an XHR fallback would mean
reintroducing the blind reload this replaces.
- Only one check is outstanding at a time, and each one has a deadline. A
proxy can accept a connection and never finish the response; without the
deadline that promise never settles and the tab quietly stops asking, even
after the site returns.
- The destination is checked against the site's own origin, on the server and
again in the browser. A visitor who arrives on a crafted path is sent to
`/` rather than off the site — the maintenance page is a page people are
told to trust and wait on, which makes it an unusually good place from
which to bounce someone somewhere else.

### Turning it off

Set [`DJANGO_COUNTDOWN_POLL_INTERVAL`](../reference/settings.md#django_countdown_poll_interval)
to `0`. The status line and the script disappear, the endpoint stays. The
page then has no way to notice the site is back — visitors refresh by hand.

## Writing your own variant

Expand Down Expand Up @@ -94,7 +186,8 @@ Every available block, with its default value, is listed in
- **`blocked_body`** replaces the whole page body. Use it when the structure
itself is wrong for you — but note the countdown script lives *outside*
that block and keeps running, so keep the element IDs it expects
(`countdown-display`, `countdown-value`) if you want the timer to work.
(`countdown-label`, `countdown-value`) if you want the timer to work. The
background poll runs either way.

## Replacing the shipped templates wholesale

Expand Down Expand Up @@ -167,3 +260,9 @@ status: temporary, do not deindex. The package does not set a `Retry-After`
header. If a CDN or reverse proxy sits in front of your site, check that it
does not cache 503 responses — otherwise the maintenance page can outlive the
window it was announcing.

The status endpoint sends `Cache-Control: no-store, no-cache,
must-revalidate` and the page fetches it with `cache: 'no-store'`. A cached
answer there is worse than no answer at all: every waiting visitor would be
told the site is still down long after it came back, or — the other way
round — sent to a site that is not up yet.
36 changes: 25 additions & 11 deletions docs/guide/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ from opposite ends of the request:

```mermaid
flowchart TD
R([Request]) --> M{"Path starts with<br/>/admin/, /static/, /media/?"}
R([Request]) --> Q{"Path is the<br/>status endpoint?"}
Q -- yes --> J([JSON: blocked true/false/null<br/>HTTP 200, always])
Q -- no --> M{"Path starts with<br/>/admin/, /static/, /media/?"}
M -- yes --> PASS([Pass through])
M -- no --> S{"Current Site<br/>resolvable?"}
S -- no --> LOG[Log exception] --> PASS
Expand All @@ -54,6 +56,10 @@ request never reaches your view, your ORM queries or your templates.
`countdown_context` runs the same checks to decide which banner — if any —
your own templates should render.

The two outcomes on the right come from the same function,
`get_blocking_countdown()`. The status endpoint is not a second opinion about
whether the site is up — it is the same verdict, in a form a script can read.

## Who sees what

This is the table worth keeping in mind. "Public" means anonymous users *and*
Expand Down Expand Up @@ -108,8 +114,12 @@ Three URL prefixes are never blocked, no matter the state:
| `/static/` | So the maintenance page can load its own stylesheet |
| `/media/` | So user-uploaded assets referenced by the page still resolve |

These are hardcoded in `CountdownBlockingMiddleware.process_request` and
cannot be configured. If your admin lives at a different path — a common
The status endpoint — `/__countdown_status__/` unless you moved it — is open
as well, and is handled before the three prefixes above.

The prefixes are hardcoded in `CountdownBlockingMiddleware.process_request`
and cannot be configured. They are matched against the path as your
`URLconf` sees it, so an application mounted under a prefix keeps them. If your admin lives at a different path — a common
hardening measure — see the caveat in
[Settings](../reference/settings.md#exempt-url-prefixes).

Expand Down Expand Up @@ -149,11 +159,15 @@ themselves along with a little JavaScript:
- **Countdown banner** — ticks every second; when it reaches zero it shows
"Maintenance running" and reloads the page after 3 seconds, which is the
request that produces the 503.
- **Maintenance page, bounded** — ticks down to `maintenance_until`, then
shows "Maintenance finished!" and reloads after 3 seconds.
- **Maintenance page, indefinite** — no timer to show, so it simply reloads
every 30 seconds until the site comes back.

All timers are computed from ISO-8601 timestamps rendered into the page and
compared against the browser clock, so a visitor with a badly skewed clock
sees a skewed timer. The actual blocking decision is always made server-side.
- **Maintenance page** — the clock reports, the poll decides. The timer only
updates what the visitor reads; navigating back into the site happens
solely when the status endpoint has confirmed there is a site to navigate
to. See [Waiting for the site to come
back](blocked-page.md#waiting-for-the-site-to-come-back).

The banner's timer is computed from an ISO-8601 timestamp rendered into the
page and compared against the browser clock, so a visitor with a badly skewed
clock sees a skewed banner. The maintenance page has the same skew, but it is
inert there: the clock never triggers anything, and every poll refreshes the
end time from the server. The actual blocking decision is always made
server-side.
7 changes: 4 additions & 3 deletions docs/guide/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ Notes for a clean pull request:
- Keep the placeholders intact. Several strings use named interpolation —
`%(n)d days`, `{{ minutes }}` inside `{% blocktrans %}` — and the
substitution breaks if a name is changed.
- Translate the JavaScript labels too (`days`, `h`, `min`, `sec`,
`Maintenance finished!`). They are ordinary `{% trans %}` strings rendered
into the script.
- Translate the JavaScript labels too — the duration units (`days`, `h`,
`min`, `sec`) and the maintenance page's status line, which is what
visitors read while they wait for the site to come back. They are ordinary
`{% trans %}` strings rendered into the script.

## The example project

Expand Down
Loading