Skip to content

feat(blocked-page): wait for the site to come back instead of reloading blindly - #4

Merged
mpasternak merged 6 commits into
mainfrom
feat/blocked-page-health-polling
Aug 25, 2026
Merged

feat(blocked-page): wait for the site to come back instead of reloading blindly#4
mpasternak merged 6 commits into
mainfrom
feat/blocked-page-health-polling

Conversation

@mpasternak

Copy link
Copy Markdown
Member

The problem

The maintenance page reloaded itself on a timer — three seconds after the countdown expired, or every 30 seconds during an indefinite window:

if (distance < 0 && !hasRefreshed) { ...; setTimeout(function () { window.location.reload(); }, 3000); }

That timer runs on the visitor's clock against a timestamp baked into the HTML. It knows the plan, not the reality, and happily reaches zero while the new container is still starting. The reload then lands on the proxy's 502 page — where no script is left to try again and the only way back is a manual refresh.

A deployment window is three states, not one:

  1. the old server is still running and still blocking,
  2. nothing is answering while the container is replaced (nginx 502/504),
  3. the new server is up and the countdown is gone.

The change

The page now polls a status endpoint in the background and navigates only on a confirmed answer. The clock reports; the poll decides.

What comes back What the visitor sees
200 + "blocked": true "Checking whether the server is back…"
200 + "blocked": true, planned end passed "Maintenance is running longer than planned…"
5xx / timeout / refused / JSON that isn't ours "The server is restarting — please wait…"
200 + "blocked": false "The system is available again…", then the page they originally asked for

When the planned end passes with the site still down, the timer's label flips 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.

Why HTTP 200 in every case

A reverse proxy with no upstream answers 502/503 on its own. A status endpoint using 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.

{"service": "django-countdown", "blocked": true, "maintenance_until": "2026-08-25T09:36:21+00:00"}

Where it lives

In the middleware, at a configurable path matched before anything else in process_request — no URLconf entry to add, and it answers even while the rest of the site is blocked. The blocking decision moved into get_blocking_countdown(), shared by the middleware and the endpoint, so the page a browser is shown and the answer it polls for cannot disagree.

Smaller details, all deliberate

  • Each poll carries the current maintenance_until back into the timer, so extend_countdown corrects pages that are already open, without a reload.
  • Polls are scheduled at the interval ±20 % of jitter — the end of a window otherwise wakes every waiting tab at once against a server that has just started.
  • Polling pauses while the tab is hidden and fires immediately on return.
  • The return trip uses location.replace(), so the maintenance page stays out of history and a page rendered for a POST is not resubmitted.

New settings

Setting Default Purpose
DJANGO_COUNTDOWN_STATUS_PATH /__countdown_status__/ Where the poll goes
DJANGO_COUNTDOWN_POLL_INTERVAL 10 Seconds between polls; 0 disables polling entirely

Verification

16 new tests (endpoint state matrix, no-store, custom path, exemption, template wiring), 219 passing overall.

Driven end to end in Chromium against a real server killed and restarted mid-window:

Timer Status line Outcome
Window running 1 min 22 sec "Checking whether the server is back…" page holds
extend_countdown while open 29 min 55 sec unchanged timer corrected itself, no reload
Timer at zero, server dead Planned end exceeded by: 7 sec "The server is restarting…" page still ours, not the proxy's
Server back, window over navigated to /healthz/, body ok

No JS errors. mkdocs build --strict clean, ruff and all pre-commit hooks pass.

Known limitation

The timer compares a server timestamp against the browser clock, so a visitor with a badly skewed clock sees a skewed timer — as before. What changed is that this no longer matters: the clock triggers nothing, and every poll refreshes the end time from the server. Full correction would mean sending the server's "now" and computing an offset; out of scope here.

🤖 Generated with Claude Code

https://claude.ai/code/session_019oAEVGbrDydoJJ6QUYx3Wr

mpasternak and others added 6 commits August 25, 2026 13:21
…ng blindly

The maintenance page used to reload itself on a timer: three seconds after
the countdown expired, or every 30 seconds during an indefinite window. Both
reloads assumed the site would be back by the time they fired. During a real
deployment it often is not — the container is still starting and the proxy in
front answers 502 out of its own pocket — and the visitor lands on an error
page where no script is left to try again.

The page now polls a status endpoint in the background and navigates only on
a confirmed answer. The timer became a display: when the planned end passes
with the site still down, the label flips to "Planned end exceeded by:" and
counts the overrun rather than announcing a finish that has not happened.

The endpoint answers HTTP 200 in every case, 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.

It lives in the middleware at a configurable path matched before anything
else, so it needs no URLconf entry and answers while the rest of the site is
blocked. The blocking decision moved into get_blocking_countdown(), shared by
the middleware and the endpoint, so the page a browser is shown and the
answer it polls for cannot disagree.

Polls carry the current maintenance_until back into the timer, so extending a
running window corrects pages that are already open. They are scheduled at
the interval +/-20 % of jitter, pause while the tab is hidden, and return via
location.replace() so the maintenance page stays out of history and a page
rendered for a POST is not resubmitted.

Verified end to end in Chromium against a real server being killed and
restarted mid-window: still-blocked, unreachable, overdue-and-unreachable,
and recovered all behave as described, with no JS errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oAEVGbrDydoJJ6QUYx3Wr
The blocked page navigates to request.get_full_path() once the site is back.
A path beginning with "//" — or with a backslash, which browsers read as a
slash — resolves as an address of its own, so that navigation would leave the
site entirely. The maintenance page is an unusually effective place to bounce
someone from: it is a page visitors are told to trust and wait on, on the
real domain, with the real branding.

Django's development server and gunicorn both normalise such paths away
before the request arrives — neither reproduces this. uWSGI hands the path
through intact, and nginx only collapses duplicate slashes while
merge_slashes is on. A library cannot know which stack it runs under, so the
check is made here.

Validated server-side with Django's own url_has_allowed_host_and_scheme(),
falling back to "/", and again in the browser against window.location.origin
immediately before navigating. The second gate matters because the return URL
can also be handed in by a custom view that renders this template itself,
bypassing the middleware entirely.

Backslash paths need no rejection: get_full_path() percent-encodes them, and
%5C stays inside the site. There is a test pinning that, so the day the
encoding changes the path does not quietly become protocol-relative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oAEVGbrDydoJJ6QUYx3Wr
The poller kept a single timer id in `pending` and started a fresh poll
whenever the tab became visible. If that happened while a fetch was still in
flight, the clearTimeout was a no-op — that timer had already fired — so a
second chain began. Both chains then resolved and called scheduleNext(), the
second assignment overwrote the first timer's id, and the orphaned timer went
on firing forever, unclearable. Every hide/show cycle doubled the polling
rate.

It lands exactly where it hurts most: a fetch is only in flight long enough
to be caught by a tab switch when the server is slow, which is what a server
mid-deployment is. That undoes the jitter, which exists precisely to keep
waiting tabs from stampeding a server that has just started.

A single in-flight flag fixes it: the request already on its way reschedules
for everyone, and a tab revealed mid-flight waits for it instead of starting
a rival chain.

Reproduced in Chromium with status responses delayed 2.5 s and document.hidden
stubbed: three hide/show cycles took the rate from 0.22 to 0.42 polls/s
before, and left it flat after.

Found by review, not by the test suite — JS scheduling is not reachable from
the Django tests, so a guard test pins the flag's presence and the behaviour
is verified in a browser harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oAEVGbrDydoJJ6QUYx3Wr
Review turned up a hole under the feature's central promise. `except
Exception` around the Site lookup turned "I could not tell" into `None`, and
`build_status_response()` read `None` as a confirmed unblocking. A worker
that has started but cannot reach the database yet — the ordinary shape of a
deployment — therefore answered `blocked: false`, and the waiting page sent
the visitor straight to the error page this whole mechanism exists to avoid.
It said "come in" precisely when it had no idea.

`blocked` now has three values. `null` means unreadable, and only an explicit
`false` is permission to leave. `get_blocking_countdown()` raises instead of
swallowing, so `None` keeps meaning "nothing blocks this request" and nothing
else; the two callers then answer the same unknown differently — the
middleware still fails open, which is what the documentation has always
promised and what the code only partly did.

Four smaller findings from the same review:

- A hung fetch left `inFlight` raised forever and the tab stopped asking, even
  after the site returned. Every request now has a deadline via
  AbortController, so a proxy that accepts a connection and never answers
  costs one interval rather than the whole recovery.
- A custom `blocked_body` without the clock's elements threw on the first
  tick, which aborted the script before the poller ever started — losing the
  number took the whole recovery with it. The elements are checked; the poll
  runs either way.
- Mounted under a prefix, the middleware compared `request.path` (which
  carries SCRIPT_NAME) against an unprefixed setting, and handed the browser
  an unprefixed URL to ask for. Matching moved to `path_info`, and the page
  is given the prefixed URL.
- A negative `DJANGO_COUNTDOWN_POLL_INTERVAL` is a delay the browser runs
  immediately, turning every waiting tab into a request loop. Floored at zero.

Verified in Chromium: a stubbed `blocked: null` holds the page on "The server
is restarting", and flipping the same endpoint to `false` against a cleared
countdown navigates through to the requested page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oAEVGbrDydoJJ6QUYx3Wr
Both found by the second review round, and both are mine rather than the
feature's.

The exempt prefixes still matched request.path while the status endpoint one
line above had just moved to path_info. Under a mount prefix, request.path
carries it, so "/tenant/admin/login/" stops matching "/admin/" and all three
always-open prefixes close during a window. That takes the admin login page
out of reach precisely when someone needs it: the superuser bypass requires
being logged in, and the door to logging in was the exempt prefix. Serving
static through Django, the blocked page also gets served itself in place of
its stylesheet.

The interval floor introduced a worse one. max(0, "10") raises TypeError, and
settings are commonly read from the environment, which yields strings — so a
configuration that worked before this branch now breaks. get_poll_interval()
runs while rendering the blocked page, outside process_request's fail-open
guard, so that raise answers every visitor with a 500 for the whole window,
in place of the page explaining it. The fix that was meant to stop a request
loop could take the site down instead.

Strings are now converted, and a value that is no kind of number logs a
warning and falls back to the default. Failing open is the rule this package
is built on; the newest code was the code breaking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oAEVGbrDydoJJ6QUYx3Wr
…aScript

The poll interval went into the script as a plain template variable, and
Django formats numbers for humans. With USE_THOUSAND_SEPARATOR on, an
interval of 1800 renders as:

    var pollInterval = 1,800 * 1000;

which is a SyntaxError. Not a broken interval — a broken script. The parser
gives up on the whole block, so the clock dies with the poller, silently, on
a page whose entire job is to reassure someone that things are under control.
It needs no misconfiguration: a legitimate setting plus a locale setting the
package never sees.

unlocalize on the one number this package hands to JavaScript. The banner's
"{{ minutes }} minutes" stays localised, as it should be — it is prose.

Also from the same review round:

- The guide told anyone writing a custom blocked_body to keep
  "countdown-display" and "countdown-value". The script has looked for
  "countdown-label" and "countdown-value" since the overdue label landed, so
  a template following the guide lost its clock.
- The quickstart still described the blind reloads this branch removed, and
  the how-it-works diagram still showed a two-valued blocked field.
- Documented that the mechanism needs fetch and AbortController, and what a
  browser without them gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oAEVGbrDydoJJ6QUYx3Wr
@mpasternak
mpasternak merged commit e542a7c into main Aug 25, 2026
15 checks passed
@mpasternak
mpasternak deleted the feat/blocked-page-health-polling branch August 25, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant