|
| 1 | +--- |
| 2 | +status: provisional |
| 3 | +stage: alpha |
| 4 | +--- |
| 5 | + |
| 6 | +# Branded Data-Plane Error Pages |
| 7 | + |
| 8 | +- [Summary](#summary) |
| 9 | +- [Motivation](#motivation) |
| 10 | + - [Background: what a customer sees when something breaks](#background-what-a-customer-sees-when-something-breaks) |
| 11 | + - [The regression: the branded page went silently dark](#the-regression-the-branded-page-went-silently-dark) |
| 12 | + - [Goals](#goals) |
| 13 | + - [Non-Goals](#non-goals) |
| 14 | +- [Proposal](#proposal) |
| 15 | + - [User Stories](#user-stories) |
| 16 | + - [Risks and Mitigations](#risks-and-mitigations) |
| 17 | +- [Design Details](#design-details) |
| 18 | + - [Where the page is served](#where-the-page-is-served) |
| 19 | + - [Which responses get the page](#which-responses-get-the-page) |
| 20 | + - [Content ownership: a ConfigMap, with a baked-in fallback](#content-ownership-a-configmap-with-a-baked-in-fallback) |
| 21 | + - [Updating the page](#updating-the-page) |
| 22 | + - [Interaction with the Connector "tunnel offline" message](#interaction-with-the-connector-tunnel-offline-message) |
| 23 | + - [Verifying the experience](#verifying-the-experience) |
| 24 | + - [Transition Plan](#transition-plan) |
| 25 | +- [Drawbacks](#drawbacks) |
| 26 | +- [Alternatives](#alternatives) |
| 27 | + |
| 28 | +## Summary |
| 29 | + |
| 30 | +When a customer's site is reached through Datum's edge — a Connector-backed |
| 31 | +proxy, or any Gateway on the downstream data plane — and something goes wrong |
| 32 | +(the tunnel is offline, the origin is down, a request times out), the visitor's |
| 33 | +browser gets a response generated by Envoy, not by the customer's application. |
| 34 | +Today, on the downstream/Connector data plane, that response is a **raw, |
| 35 | +unstyled error**: an HTTP 503 with a bare body like `no healthy upstream` and no |
| 36 | +indication of where it came from or what to do next. |
| 37 | + |
| 38 | +Datum already authored a **branded HTML error page** for exactly this moment. It |
| 39 | +was delivered through an `EnvoyPatchPolicy`, but that mechanism was switched off |
| 40 | +on the downstream data plane as part of the |
| 41 | +[Envoy Gateway Extension Server](../envoy-gateway-extension-server/README.md) |
| 42 | +migration. As a result the branded page has been **inert for months** on the |
| 43 | +Connector data plane, and visitors have been seeing the raw Envoy output instead. |
| 44 | + |
| 45 | +This enhancement restores the branded error experience by moving its delivery |
| 46 | +into the extension server (the supported path now that `EnvoyPatchPolicy` is |
| 47 | +disabled there), and makes the page's **content** a configuration artifact — a |
| 48 | +mounted `ConfigMap` — so it can be updated by GitOps without a code release. |
| 49 | + |
| 50 | +## Motivation |
| 51 | + |
| 52 | +### Background: what a customer sees when something breaks |
| 53 | + |
| 54 | +Datum's network services route a visitor's request through edge Envoy to a |
| 55 | +customer's backend — either directly, or, for the **Connector**, through an |
| 56 | +outbound tunnel to a private origin. When that path can't be completed, Envoy |
| 57 | +itself returns the response. Common cases on the Connector data plane: |
| 58 | + |
| 59 | +- The Connector tunnel is **offline** (agent not running / not yet connected). |
| 60 | +- The origin behind the tunnel or backend is **down or unreachable**. |
| 61 | +- The request **times out** or trips a circuit breaker. |
| 62 | + |
| 63 | +In all of these, the visitor never reaches the customer's app, so the customer's |
| 64 | +own error page can't be shown. The edge is the only thing that can speak — and |
| 65 | +what it says, and how it looks, is Datum's responsibility. A blank `503` with |
| 66 | +`no healthy upstream` reads like a broken platform; a branded page that says |
| 67 | +"this site is temporarily unavailable" with Datum's identity reads like a |
| 68 | +platform that's working as intended and tells you what's happening. |
| 69 | + |
| 70 | +This is a **trust and brand-perception** surface. It is the page a customer's |
| 71 | +*own* end-users see when the customer is mid-incident — precisely when the |
| 72 | +experience matters most. |
| 73 | + |
| 74 | +### The regression: the branded page went silently dark |
| 75 | + |
| 76 | +Datum built the branded page (an `<!doctype html>` document, served as |
| 77 | +`text/html`) and shipped it to the downstream gateway as an `EnvoyPatchPolicy` |
| 78 | +named `local-reply-5xx`, matching any response with status ≥ 500. |
| 79 | + |
| 80 | +The [extension-server migration](../envoy-gateway-extension-server/README.md) |
| 81 | +intentionally set `enableEnvoyPatchPolicy: false` on the downstream Envoy Gateway |
| 82 | +— that was the whole point of moving WAF and Connector logic off |
| 83 | +`EnvoyPatchPolicy`. But the branded error page was **never migrated** to the |
| 84 | +extension server. Disabling `EnvoyPatchPolicy` disabled *every* such policy on |
| 85 | +that data plane, including the error page, with no signal to a customer or |
| 86 | +operator that the experience had degraded. The policy still exists in Git and on |
| 87 | +the cluster; it is simply `Accepted=False` and never reaches Envoy. |
| 88 | + |
| 89 | +Net effect: on the Connector / downstream data plane, every edge-generated 5xx — |
| 90 | +including the very common "Connector is offline" case — is served as a bare |
| 91 | +Envoy body. The branded experience that product intended is not being delivered. |
| 92 | + |
| 93 | +### Goals |
| 94 | + |
| 95 | +- Restore the **branded HTML error page** for edge-generated 5xx responses on the |
| 96 | + downstream / Connector data plane, at parity with what the `EnvoyPatchPolicy` |
| 97 | + delivered. |
| 98 | +- Make it work **without `EnvoyPatchPolicy`**, using the extension server, which |
| 99 | + is the supported customization path on that data plane. |
| 100 | +- Cover **all** customer-facing listeners on that data plane, including HTTP/3 / |
| 101 | + QUIC, so the experience is consistent regardless of how the browser connects. |
| 102 | +- Let the page's **content be updated independently of code** — a content edit |
| 103 | + should be a GitOps change, not an operator image build and release. |
| 104 | +- **Fail safe**: a missing or malformed page must never take the data plane down |
| 105 | + or block configuration updates. |
| 106 | + |
| 107 | +### Non-Goals |
| 108 | + |
| 109 | +- Changing **which** status codes are considered errors, or the HTTP status code |
| 110 | + returned. We brand the body; we do not alter routing or response codes. |
| 111 | +- Per-customer or per-Gateway custom error pages. This proposal delivers one |
| 112 | + Datum-branded page across the data plane. Per-tenant theming is possible future |
| 113 | + work and is called out as a non-goal here. |
| 114 | +- The **standard** (non-downstream) gateways, which still run `EnvoyPatchPolicy` |
| 115 | + and already serve the branded page. They are out of scope and unchanged. |
| 116 | +- Replacing application-level error pages. When the customer's origin is |
| 117 | + reachable, its own responses are returned unchanged. |
| 118 | + |
| 119 | +## Proposal |
| 120 | + |
| 121 | +Deliver the branded error page through the extension server's existing |
| 122 | +per-listener mutation pass. During Envoy Gateway's translation, the extension |
| 123 | +server already rewrites listeners (for the WAF) and routes/clusters (for the |
| 124 | +Connector). We add one more listener mutation: attach a **local reply |
| 125 | +configuration** that renders the branded HTML for any response with status |
| 126 | +≥ 500. The page's bytes come from a mounted `ConfigMap`, with a copy compiled |
| 127 | +into the operator as an always-valid fallback. |
| 128 | + |
| 129 | +### User Stories |
| 130 | + |
| 131 | +- **As a visitor to a customer's site that is temporarily unavailable**, I see a |
| 132 | + clear, branded "temporarily unavailable" page instead of a blank or cryptic |
| 133 | + error, so I understand the site (not my browser) has a problem and that it's |
| 134 | + likely transient. |
| 135 | +- **As a Datum customer whose Connector is offline**, when my users hit my proxy |
| 136 | + they get a polished, on-brand page rather than `no healthy upstream`, so an |
| 137 | + outage on my side doesn't look like a broken platform. |
| 138 | +- **As Datum's brand/design owner**, I can update the wording and styling of the |
| 139 | + edge error page by changing a single content artifact in Git, and have it roll |
| 140 | + out across the edge without waiting on an operator release. |
| 141 | +- **As a Datum operator**, I can confirm the branded page is actually being |
| 142 | + served, and I'm confident that a bad content edit can't break traffic — the |
| 143 | + edge falls back to a known-good page. |
| 144 | + |
| 145 | +### Risks and Mitigations |
| 146 | + |
| 147 | +| Risk | Mitigation | |
| 148 | +| --- | --- | |
| 149 | +| A missing/empty/typo'd `ConfigMap` removes the page or, worse, blocks config updates (the downstream extension hook is fail-closed). | The operator ships an **embedded default** page and treats the `ConfigMap` purely as an *override*. The mount is `optional`. If the override is absent or unreadable, the embedded page is used; the hook never errors on content. | |
| 150 | +| A content edit appears not to take effect. | Editing a mounted `ConfigMap` does not by itself reach Envoy; the new content only applies on the next Envoy Gateway translation. We make content changes **roll the extension server** (which forces a fresh translation) so propagation is deterministic. See [Updating the page](#updating-the-page). | |
| 151 | +| The branded page overrides the Connector's terse "tunnel offline" signal used by tunnel clients. | The page is scoped to browser-facing responses; the tunnel-control response is preserved or treated as a deliberate, documented decision. See [Interaction with the Connector "tunnel offline" message](#interaction-with-the-connector-tunnel-offline-message). | |
| 152 | +| Drift between the embedded fallback and the live `ConfigMap`. | The `ConfigMap` is the single source of truth in infra; the embedded copy is a minimal, rarely-changed safety net, not a second design to maintain. | |
| 153 | + |
| 154 | +## Design Details |
| 155 | + |
| 156 | +> This section stays at the product/architecture level. The translation hook, |
| 157 | +> security, and re-translation machinery are described in detail in the |
| 158 | +> [Envoy Gateway Extension Server](../envoy-gateway-extension-server/README.md) |
| 159 | +> enhancement; this proposal reuses them. |
| 160 | +
|
| 161 | +### Where the page is served |
| 162 | + |
| 163 | +The extension server already participates in every Envoy Gateway translation for |
| 164 | +the downstream data plane and already mutates listeners. We add a per-listener |
| 165 | +step that attaches Envoy's **local reply** configuration to each customer-facing |
| 166 | +HTTP connection manager. "Local reply" is Envoy's term for a response Envoy |
| 167 | +generates itself (an upstream failure, a timeout, a direct response) — exactly |
| 168 | +the responses a customer's app can't answer. Attaching the branded body there |
| 169 | +means every such response on that listener renders the page. |
| 170 | + |
| 171 | +Because the step runs over **all** listeners the translation produces, it |
| 172 | +naturally covers both the TLS (HTTP/1.1 + HTTP/2) and the QUIC / HTTP/3 |
| 173 | +listeners. The previous `EnvoyPatchPolicy` had to hand-target each listener by |
| 174 | +name and was extended specifically to cover HTTP/3; that maintenance burden and |
| 175 | +class of "we forgot a listener" bug goes away. |
| 176 | + |
| 177 | +Internal, non-customer listeners (for example Envoy Gateway's own readiness |
| 178 | +endpoint) are **excluded** by construction, so health checks and infrastructure |
| 179 | +endpoints are never branded. |
| 180 | + |
| 181 | +### Which responses get the page |
| 182 | + |
| 183 | +Parity with the original policy: **any edge-generated response with status |
| 184 | +≥ 500** is rendered as the branded page (configurable threshold). The original |
| 185 | +HTTP status code is preserved — a 503 stays a 503 — only the body and |
| 186 | +content-type are replaced. A runtime switch is retained so the behavior can be |
| 187 | +disabled in an emergency without a redeploy. |
| 188 | + |
| 189 | +### Content ownership: a ConfigMap, with a baked-in fallback |
| 190 | + |
| 191 | +The page is **content**, owned by brand/design, not by operator code. So: |
| 192 | + |
| 193 | +- The HTML lives in a **`ConfigMap`** managed in the infrastructure (GitOps) |
| 194 | + repository — the same place the page lives today, expressed as configuration |
| 195 | + instead of an `EnvoyPatchPolicy`. |
| 196 | +- The extension server **mounts** that `ConfigMap` to disk (an `optional` |
| 197 | + volume) and reads the file at startup. |
| 198 | +- The operator also carries a **compiled-in default** page. If the mounted |
| 199 | + override is missing or unreadable, the default is used. |
| 200 | + |
| 201 | +This split gives us the best of both: content is editable by GitOps without an |
| 202 | +operator release, and there is always a valid page even if the override is |
| 203 | +absent — which is essential because the downstream extension hook is fail-closed |
| 204 | +(a hard error would otherwise stall configuration for the whole data plane). |
| 205 | + |
| 206 | +A minimal operator configuration block selects the behavior; the bytes come from |
| 207 | +the mount or the embedded fallback: |
| 208 | + |
| 209 | +```yaml |
| 210 | +gateway: |
| 211 | + errorPage: |
| 212 | + enabled: true |
| 213 | + bodyPath: /etc/datum/error-pages/error-5xx.html # optional; falls back to embedded default |
| 214 | + minStatusCode: 500 |
| 215 | +``` |
| 216 | +
|
| 217 | +### Updating the page |
| 218 | +
|
| 219 | +A `ConfigMap` edit lands on disk but does **not** automatically reach Envoy: the |
| 220 | +extension server only influences Envoy during a translation, and Envoy Gateway |
| 221 | +does not watch the extension server's mounted files. So a content change is only |
| 222 | +applied on the **next** translation. |
| 223 | + |
| 224 | +To make updates deterministic, a content change **rolls the extension server** |
| 225 | +(via a content checksum on the Deployment or a reload controller). The restart |
| 226 | +re-reads the file and the extension server's reconnection drives a fresh |
| 227 | +translation, so the new page propagates predictably across the edge. This mirrors |
| 228 | +the broader re-translation considerations covered in the extension-server |
| 229 | +enhancement. |
| 230 | + |
| 231 | +### Interaction with the Connector "tunnel offline" message |
| 232 | + |
| 233 | +The Connector path has a second, separate signal: a terse `503 "Tunnel not |
| 234 | +online"` returned on the tunnel-control (HTTP `CONNECT`) path, used by tunnel |
| 235 | +clients rather than browsers. Normal end-users never traverse that path — their |
| 236 | +browser requests are ordinary `GET`s — so the **visitor-facing** experience is |
| 237 | +governed entirely by the branded page described here. |
| 238 | + |
| 239 | +Because the branded page matches any response ≥ 500, it would, if applied |
| 240 | +indiscriminately, also rewrite that tunnel-control 503. Two acceptable options: |
| 241 | + |
| 242 | +1. **Brand everything ≥ 500** (simplest; matches the original "brand any 5xx" |
| 243 | + intent). Tunnel clients are robust to body changes and key on status, not body. |
| 244 | +2. **Preserve the terse tunnel-control body** by scoping the page to |
| 245 | + visitor-facing responses. |
| 246 | + |
| 247 | +We default to (1) for simplicity and revisit only if a tunnel client needs the |
| 248 | +terse body. Either way, the visitor experience — the thing this proposal is |
| 249 | +about — is the branded page. |
| 250 | + |
| 251 | +### Verifying the experience |
| 252 | + |
| 253 | +- A browser request to a proxy whose Connector is **offline** returns a `503` |
| 254 | + whose body is the **branded HTML** (correct `content-type: text/html`), not |
| 255 | + `no healthy upstream`. |
| 256 | +- The same holds for an **origin-down** backend and for a **timeout**. |
| 257 | +- Envoy Gateway's own **readiness** endpoint and other internal listeners are |
| 258 | + **not** branded. |
| 259 | +- Editing the `ConfigMap` and letting the rollout complete changes the served |
| 260 | + page; removing the `ConfigMap` falls back to the embedded default with no |
| 261 | + traffic impact. |
| 262 | +- HTTP/3 / QUIC clients get the same branded page as HTTP/2 clients. |
| 263 | + |
| 264 | +### Transition Plan |
| 265 | + |
| 266 | +1. Ship the extension-server delivery and embedded fallback (page renders from |
| 267 | + the baked-in default even before any `ConfigMap` exists). |
| 268 | +2. Move the existing HTML from the `local-reply-5xx` `EnvoyPatchPolicy` into the |
| 269 | + `ConfigMap`; wire the mount and the content-change rollout. |
| 270 | +3. Verify the branded page on the downstream data plane in staging, then |
| 271 | + production, using the checks above. |
| 272 | +4. Decommission the now-redundant downstream `local-reply-5xx` `EnvoyPatchPolicy` |
| 273 | + app. The standard-gateway error page (still `EnvoyPatchPolicy`-based) is left |
| 274 | + untouched. |
| 275 | + |
| 276 | +## Drawbacks |
| 277 | + |
| 278 | +- It adds one more responsibility to the extension server, which is on the |
| 279 | + critical translation path for the data plane. This is mitigated by the |
| 280 | + embedded fallback and fail-safe handling, and the work is small and isolated. |
| 281 | +- Content updates are not instantaneous: they require a translation, which we |
| 282 | + trigger via a controlled rollout. This is inherent to the extension-server |
| 283 | + model and is an acceptable trade for not coupling content to code releases. |
| 284 | +- Two copies of the page exist (the live `ConfigMap` and the embedded fallback). |
| 285 | + The fallback is intentionally minimal to keep this cost low. |
| 286 | + |
| 287 | +## Alternatives |
| 288 | + |
| 289 | +- **Re-enable `EnvoyPatchPolicy` just for the error page.** Rejected: it |
| 290 | + reintroduces the exact mechanism the data plane deliberately moved off, and the |
| 291 | + scaling/operability reasons for disabling it still apply. |
| 292 | +- **Bake the page only into the operator image (no `ConfigMap`).** Simpler, and a |
| 293 | + reasonable starting point, but every wording or styling tweak becomes an |
| 294 | + operator release. The `ConfigMap` keeps content a GitOps concern; the embedded |
| 295 | + copy is retained as a fallback, not as the primary source. |
| 296 | +- **Serve the error page from a separate service** the edge routes to on failure. |
| 297 | + More moving parts, a new failure domain on the unhappy path, and added latency |
| 298 | + exactly when the system is already degraded. The local-reply approach keeps the |
| 299 | + page entirely within the edge proxy. |
| 300 | +- **Do nothing.** Visitors continue to see raw `no healthy upstream` bodies on |
| 301 | + customer outages, undercutting the trust and brand experience the page was |
| 302 | + built to provide. |
0 commit comments