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