info@altius-group.ch
Froideville, Vaud
IT

The flag that was written and never read

/ 11 min di lettura / aggiornato 04.09.2026

Here is a bug with no stack trace, no error rate and no alert.

A customer’s subscription is cancelled. The Stripe webhook fires, does exactly what it was written to do, and logs a confident line:

Tenant désactivé

Client.is_active is now False. The write succeeded. The log is accurate.

And the customer’s site keeps serving. Their /cms/ console keeps working. Their staff keep logging in. Indefinitely.

Because is_active was read by no code on the request path.

"""Pourquoi ce middleware existe : `Client.is_active = False`, posé par les
webhooks Stripe à l'annulation ou à l'impayé terminal, n'était lu par
AUCUN code du chemin de requête. Le webhook journalisait « Tenant
désactivé » pendant que le site public du tenant et sa console `/cms/`
continuaient d'être servis normalement, indéfiniment.
"""

This is the suspension middleware in names.legal, and the reason to write about it is not the middleware — it is thirty lines. It is that the entire failure was a write with no reader, and that every one of the four exemptions it needed turned out to be load-bearing.

Write-only state is the quietest class of bug

Nothing here is broken in any way a test or a monitor detects. The webhook has 100% success. The database is consistent. Every request returns 200 — correctly, by the rules of the code that is actually running.

The gap is between what a field means and what the system does. Somebody named a column is_active, and the name is so obviously self-explanatory that nobody checked whether anything acted on it. The word active did the work that code was supposed to do.

This is not rare and it is not a junior mistake. It appears whenever a state change is introduced from the side — a webhook, an admin action, a migration, a management command — rather than from the feature that will consume it. The write gets built because the write is the story (“handle subscription cancellation”), and the read is somebody else’s ticket.

The habit that prevents it costs one sentence per state field:

Which line of code changes its behaviour because of this field?

If you cannot name the file, the field is decoration. Write the reader first, or in the same change, or accept that you have built a very reliable logging system.

Two states behind one flag

The obvious fix — suspend anything with is_active = False — is wrong, and the reason is a second meaning already living in the same column.

"""
- **`is_active=False` sans `suspended_at` n'est PAS une suspension** :
  c'est un tenant en cours de provisioning (inscription payée, schéma pas
  encore migré). Lui afficher « site suspendu » serait un contresens.
"""

A tenant that has just signed up and paid is also is_active = False: the schema is not migrated yet, so nothing can be served. Same flag, opposite situation — one customer is on the way in, the other on the way out.

Showing “your site is suspended” to somebody who has just paid is worse than showing nothing. It is the single worst message you could pick for that moment, and it is what a naive read of the flag produces.

So suspension is is_active = False and suspended_at set, and the discriminator is the timestamp. Which is a general pattern worth naming:

A boolean tells you the state. A timestamp tells you the state and when it was entered, which is what distinguishes two situations that share it.

suspended_at also gives the grace period for free:

"""
Politique appliquée : `Client.suspend()` démarre un délai de grâce de
`TENANT_SUSPENSION_GRACE_DAYS` jours pendant lequel RIEN ne change (le
client a le temps de régulariser) ; passé ce délai, toute requête sur le
tenant rend la page de suspension.
"""

Nothing changes during the grace period. Not a banner, not a degraded mode — a failed card on a Friday should not put a law firm’s website into a warning state before anyone has had a chance to look at it. The clock runs, and either the customer pays or the site stops.

503, and the reason is search engines

response = render(request, "main/tenant_suspended.html", {...}, status=503)
response["Retry-After"] = "86400"
"""
- **503, pas 404** : la suspension est temporaire. Un 404 ferait
  désindexer le site du client par les moteurs, ce qui survivrait à sa
  régularisation. `Retry-After` accompagne le 503.
"""

A 404 would get the client’s site deindexed by search engines, which would survive their payment.

This is the detail I would most want someone to take from the article, because the reasoning runs outside the application entirely.

A 404 tells a crawler the page does not exist. Repeated 404s across a whole site get it dropped from the index. That takes days to happen and weeks to recover — and the recovery does not start when the customer pays. It starts when the crawler comes back, notices, and re-earns the ranking.

So a billing dispute lasting four days, resolved with an apology, would cost the customer their search visibility for a month. The application would have inflicted a punishment far out of proportion to the problem, in a system nobody involved controls, invisibly.

503 Service Unavailable is the correct code and it is correct in a specific way: it means this exists, it is temporarily not being served, come back. Crawlers hold the index. With Retry-After, they are told roughly when.

86400 — one day — with the reasoning next to it:

# Une suspension se règle en réglant une facture : quelques heures est
# une attente plausible, et ça évite qu'un crawler revienne en boucle.

Long enough that a crawler does not loop on a site that will be down for a while; short enough to be plausible for something resolved by paying an invoice. A number chosen from the domain rather than from a list of round numbers.

The general form: an error status code is a statement to machines you will never meet, about a situation that will end. Pick the one that describes the situation, not the one that is convenient to return.

The three paths that must survive suspension

#: Chemins servis même suspendus. `/internal/` porte l'endpoint « ask » de
#: Caddy : le refuser ferait expirer le certificat du domaine, et le nom ne
#: répondrait plus DU TOUT — même pas pour afficher la page de suspension.
EXEMPT_PREFIXES = ("/internal/", "/static/", "/media/")

/internal/ is the one worth stopping on, and it is a genuinely non-obvious cross-layer dependency.

That prefix carries the on-demand TLS ask endpoint: Caddy calls it before issuing or renewing a certificate for a customer domain. Suspend it, and:

  1. The certificate is not renewed.
  2. It expires.
  3. The TLS handshake fails.
  4. The domain answers nothing — not the suspension page, not a redirect. A browser security warning.

The customer sees a certificate error and concludes the platform is broken. Nothing tells them they have an unpaid invoice, because the page that would tell them cannot be reached: TLS fails before HTTP begins.

And the failure is delayed. Certificates renew every sixty days or so, so the suspension works correctly for weeks and then, one morning, silently becomes a total outage with a scary warning. Nobody connects the two events.

The rule this yields:

A degraded mode must not degrade the machinery that delivers the degraded mode. List what your error page depends on — DNS, TLS, static assets, a session, a database — and exempt every one of them.

/static/ and /media/ are the mundane instance of the same rule. A suspension page served without its stylesheet is unstyled text that looks like a crash, at the exact moment you most need the customer to believe this is deliberate and recoverable.

The public schema is the way back

def _should_suspend(self, request, tenant):
    if tenant is None:
        return False
    if getattr(tenant, "schema_name", "public") == get_public_schema_name():
        return False
    ...
"""
- **Le schéma public n'est jamais suspendu** : c'est là que vit le tunnel
  de paiement, donc le seul chemin de retour du client.
"""

The platform’s own site is never suspended, because that is where the customer pays.

Stated that way it is obvious. It is also the mistake a generic “suspend inactive tenants” rule makes on its first day, and the resulting system has a beautiful property: a customer whose payment fails is shown a page telling them to pay, and the page where they would pay is also suspended.

The link is explicit about where it points:

@staticmethod
def _platform_url(request):
    """URL de l'espace client (là où on régularise) — jamais le tenant."""
    from core.platform_urls import platform_url
    return platform_url(request, "/pricing/subscription/")

Not a relative path. A suspended tenant is on its own domain, so any relative link stays inside the suspended site. The URL has to be absolute and point at the platform.

Note the getattr(tenant, "schema_name", "public") default: an object without a schema name is treated as public, which means do not suspend. The default of an uncertain check is the non-destructive branch. If the middleware cannot tell what it is looking at, it serves the site.

Where it sits in the stack

"""
- **Placé après `TenantLanguageMiddleware`** : la page est rendue dans la
  langue du tenant, pas en anglais par défaut.
"""

Middleware order is usually treated as folklore. Here every position is an argument, and this one has two constraints that together pin it to a single slot:

After TenantLanguageMiddleware, because the suspension page must render in the tenant’s language. A French law firm’s visitors get a French notice. Run the suspension check first and you have a page that says this site is temporarily unavailable in English to people who do not read English — for a site that has been correctly French for two years.

Before the view layer, because a suspended tenant must execute no view at all. Not a view that renders a banner: no view. Otherwise every view is a place where the check might be forgotten, and the one that forgets is the API endpoint that keeps serving data.

Two constraints, one slot. That is what it looks like when middleware ordering is reasoned about rather than inherited, and it is worth writing the reasoning next to the entry — because the next person to reorder the list will be solving an unrelated problem.

The neighbouring lesson: request.tenant may not exist

The other middleware in the same file carries a comment about a failure that is easy to hit and hard to diagnose:

# Récupérer le tenant actuel. Sur un hôte INCONNU (dev en 127.0.0.1,
# scan d'IP), django-tenants ne pose PAS request.tenant : il route
# vers le public via connection (SHOW_PUBLIC_IF_NO_TENANT_FOUND) sans
# attribut sur la requête — AttributeError sur tout /admin/ sinon.
# ... on discrimine par le schéma, pas par un attribut optionnel.
tenant = getattr(request, "tenant", None)

With SHOW_PUBLIC_IF_NO_TENANT_FOUND = True, a request for a hostname that matches no tenant is routed to the public schema — through the connection, by setting the search path. request.tenant is never assigned.

So request.tenant.schema_name raises AttributeError for a whole category of perfectly ordinary requests: local development on 127.0.0.1, a health check by service name, an IP scanner, a monitoring probe. The application works fine for every real visitor and 500s on the ones nobody is watching — which is exactly the traffic that fills your error tracker with noise you learn to ignore.

Two habits come out of it, and both generalise past django-tenants:

Read request attributes set by middleware with getattr(..., None). They are conventions, not guarantees, and the guarantee is void for any request the middleware short-circuits.

Discriminate on the authoritative source, not on the convenient one. The schema is set on the connection, always. request.tenant is a convenience that exists most of the time. Where the two disagree, the connection is right.

The suspension middleware applies the same rule at the end of _should_suspend:

is_suspended = getattr(tenant, "is_suspended", None)
return bool(callable(is_suspended) and is_suspended())

Not tenant.is_suspended(). Check that the attribute exists, check that it is callable, then call it — because tenant here is whatever the middleware stack put on the request, and a middleware whose failure mode is a 500 on every page of a paying customer’s site should not assume.

What to take away

  1. Never ship a state field without naming the code that reads it. A flag with no reader is a very reliable log line.
  2. Distinguish two situations that share a flag with a timestamp. Suspended and not-yet-provisioned are both “inactive”, and confusing them insults a customer who has just paid.
  3. Use 503 with Retry-After, never 404. Deindexing outlives the billing dispute that caused it.
  4. Exempt everything your error page depends on, especially the certificate endpoint — a degraded mode that breaks TLS produces no page at all, weeks later.
  5. Never suspend the route back. The payment funnel lives on the platform, and the link to it must be absolute.
  6. Place the check where its two constraints intersect — after the language is resolved, before any view runs — and write the reasoning next to it.
  7. getattr for anything a middleware “always” sets. Always is per-request, and short-circuits are requests too.
Pronto a cominciare?

Parliamo del suo progetto

Ci racconti le sue esigenze in IoT, GIS o sviluppo su misura — le rispondiamo entro 24 ore.