feat(blocked-page): wait for the site to come back instead of reloading blindly - #4
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
The maintenance page reloaded itself on a timer — three seconds after the countdown expired, or every 30 seconds during an indefinite window:
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:
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.
200+"blocked": true200+"blocked": true, planned end passed200+"blocked": falseWhen 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/503on 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— noURLconfentry to add, and it answers even while the rest of the site is blocked. The blocking decision moved intoget_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
maintenance_untilback into the timer, soextend_countdowncorrects pages that are already open, without a reload.location.replace(), so the maintenance page stays out of history and a page rendered for aPOSTis not resubmitted.New settings
DJANGO_COUNTDOWN_STATUS_PATH/__countdown_status__/DJANGO_COUNTDOWN_POLL_INTERVAL100disables polling entirelyVerification
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:
1 min 22 secextend_countdownwhile open29 min 55 secPlanned end exceeded by: 7 sec/healthz/, bodyokNo JS errors.
mkdocs build --strictclean, 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