Skip to content

Commit 121bd95

Browse files
alexluongclaude
andauthored
feat(webhook): proxy-aware error handling for forward proxies (#899)
* docs(webhook): document forward proxy feature and Envoy support Adds a Features page covering the existing DESTINATIONS_WEBHOOK_PROXY_URL configuration and introduces DESTINATIONS_WEBHOOK_PROXY_TYPE=envoy as an opt-in for Envoy-aware error handling: infra-error detection via x-envoy-response-flags and the proxyconnect error path, transparent redelivery via nack, and x-envoy-*/server header sanitization. Includes required Envoy configuration, a minimal reference envoy.yaml, and queue retry policy guidance for sufficient redelivery runway during proxy outages. Default behavior for existing users is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): narrow nack semantics to proxy infra only Adds an explicit error-attribution table. Nack is reserved for cases where the proxy itself is the proximate cause (auth failure, proxy unreachable). Destination failures that Envoy merely reports (upstream DNS / connect / timeout) are recorded as normal failed delivery attempts with destination-attributed codes; the wrapper sanitizes the response data so Envoy is not exposed, but the customer still sees a destination failure. This avoids the infinite-nack failure mode for genuinely broken destinations. Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): clarify dead-letter window phrasing "DLQ runway" replaced with an explicit statement of what the number means: the maximum time a single nacked message is retried before landing in the dead-letter queue. Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): drop PROXY_TYPE knob, auto-detect Envoy signals Removes DESTINATIONS_WEBHOOK_PROXY_TYPE. Envoy-specific signals (x-envoy-response-flags, x-envoy-* / server: envoy headers) are detected automatically from response headers when present; nothing to configure. The proxy-infra nack path (proxyconnect 407, dial to proxy fails) is generic and applies to any forward proxy, since the discriminator is "the proxy is broken or misconfigured," not "the proxy is Envoy." Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): split error table into generic and Envoy-specific Reorganizes the error handling section so generic-proxy behavior and Envoy-specific signals are in separate tables. Adding support for other proxy implementations (Squid, HAProxy, nginx) in the future would extend the Envoy support pattern. Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): clarify sanitization scope and proxy security trade-offs Two clarifications: 1. Response sanitization is best-effort and currently complete only for Envoy. For arbitrary proxies, Outpost can rewrite error messages but cannot reliably strip proxy-identifying content from response bodies or headers. 2. Production proxy auth + TLS recommendation now depends on topology: not required if Outpost and the proxy share a private network; strongly recommended if the proxy is reachable over the internet to prevent open-relay abuse. Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): demote production notes to a short paragraph Trim the production-deployment guidance to a single sentence on the VPC-vs-internet trade-off, instead of a subsection with specific configuration suggestions. Co-Authored-By: Claude <noreply@anthropic.com> * feat(destregistry): add proxy error sentinel types Defines ErrProxyInfra (signals nack-on-infra-error) and ErrProxyDestination (signals record-as-destination-error with mapped code) along with MapEnvoyResponseFlag for translating Envoy response flags into existing destination error codes (connection_refused, timeout, dns_error, network_unreachable). No consumers yet; subsequent commits add the transport wrapper and integration into the webhook delivery path. Co-Authored-By: Claude <noreply@anthropic.com> * feat(destwebhook): nack proxy infra errors, classify destination errors Wraps the proxy transport when a proxy URL is configured. Detects two classes of proxy-originated failures: - Proxy infrastructure (407 / 401 / 403 on CONNECT, dial-to-proxy failure): wrapped as ErrProxyInfra. ExecuteHTTPRequest returns Delivery: nil so the message handler nacks via the existing nil-attempt path (registry.go:179-195), preserving the destination's retry budget across proxy outages. - Destination errors that the proxy reports (5xx on CONNECT, etc.): wrapped as ErrProxyDestination with a classification code mapped from the response. Falls through to the standard failed-attempt path with the explicit code instead of substring-matching the underlying error. CONNECT-time detection uses http.Transport.OnProxyConnectResponse which exposes the full response (status + headers) before Go discards it, sidestepping the fragility of parsing internal error strings. The wrapper still detects dial-to-proxy failures via the "proxyconnect" prefix that Go's net.OpError keeps for that case. Co-Authored-By: Claude <noreply@anthropic.com> * feat(destwebhook): Envoy response-flag detection and header sanitization Extends proxyTransport with Envoy-specific signals layered on top of the generic proxy handling: - Plain-HTTP forwarding path: responses carrying a non-empty/non-"-" x-envoy-response-flags header are treated as Envoy-synthesized failures, converted to ErrProxyDestination with the flag mapped to a destination error code, and their bodies dropped so they don't reach the delivery record. - CONNECT path: onProxyConnectResponse reads the same flag header on non-200 responses to refine the generic "connection_refused" classification (e.g. flag DC -> dns_error, UT -> timeout). - Successful responses are stripped of x-envoy-* headers and a "server: envoy" header before being returned, so the proxy is not fingerprinted in the destination's recorded response. Co-Authored-By: Claude <noreply@anthropic.com> * test(destregistry): pin Go stdlib "proxyconnect" error wording Adds a snapshot test asserting that net/http still emits "proxyconnect" in dial-to-proxy failure errors. The proxyTransport detector relies on this internal stdlib convention; if a Go upgrade changes the wording, this test fails in CI rather than letting the detector silently stop matching and causing proxy outages to be misclassified as destination errors. Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): align tables with implementation Two small adjustments after the code landed: - Drop the implementation-specific "Outpost sees" column from the generic table; describe observed behavior instead. Proxy auth-style failures cover 407, 401, and 403, not just 407. - Frame the Envoy table around the response-flag signal directly, rather than listing every flag-to-code combination (those are documented by the MapEnvoyResponseFlag mapping). Co-Authored-By: Claude <noreply@anthropic.com> * docs(webhook): move response_headers_to_add to RouteConfiguration Envoy rejects response_headers_to_add at the HttpConnectionManager level (confirmed on Envoy 1.31). The field belongs on RouteConfiguration. Updates both the standalone snippet and the reference envoy.yaml. Co-Authored-By: Claude <noreply@anthropic.com> * fix(destwebhook): treat HTTPS responses as byte-transparent Destinations commonly sit behind their own Envoy at the edge, which emits server: envoy and x-envoy-response-flags on the response. For HTTPS destinations, that response travels through an established CONNECT tunnel — the forward proxy is byte-blind to TLS-encrypted bytes and cannot have synthesized or modified anything. Previously the wrapper treated those headers as if our forward proxy had set them, which: - converted a real destination 503 from a downed app into a misleading "connection_refused" with no body - stripped the destination's own envoy observability headers After this change, HTTPS responses (post-successful-CONNECT) are returned unchanged. Proxy-originated HTTPS failures all happen at CONNECT time and remain handled in onProxyConnectResponse. The plain-HTTP forwarding path still applies envoy detection and header stripping because the forward proxy is in the byte path on the return. The residual case where this over-strips — a plain-HTTP destination that is itself behind Envoy — is documented as a limitation; attribution remains correct because x-envoy-response-flags is overwritten by the forward Envoy via OVERWRITE_IF_EXISTS_OR_ADD. Top-of-file doc comment on proxyTransport now describes the two surfaces (CONNECT-time vs plain-HTTP-forwarding) so the dual-path design is discoverable. Co-Authored-By: Claude <noreply@anthropic.com> * chore(destwebhook): apply review nits to proxy transport - httphelper: skip ClassifyNetworkError when ErrProxyDestination sentinel matches - proxytransport: drop strings.ToLower; http.Header keys are canonicalized - proxytransport: drop impossible nil-guard on req/req.URL in RoundTrip - proxytransport: note unhandled Envoy flags fall through to network_error - proxytransport: cross-reference pin test from proxyconnect detector - proxytransport_test: drop unreachable empty-flag row from map test Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(destregistry): extract NewHTTPClient as free function MakeHTTPClient never touched BaseProvider state — it was a free helper pretending to be a method. Move it to httpclient.go and update the three HTTP-based providers (desthookdeck, destwebhook, destwebhookstandard) to call destregistry.NewHTTPClient directly. BaseProvider keeps only the metadata/validation concerns it actually owns. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(destwebhook): scope proxy transport to webhook package Move proxytransport.go, the Envoy flag mapping, and all sentinels (ErrProxyInfra, ErrProxyDestination, IsProxyInfraError) into the destwebhook package — the only place they're actually used. destregistry keeps a generic WrapTransport hook on HTTPClientConfig; destwebhook exports a WrapTransport function and the two webhook providers (destwebhook, destwebhookstandard) plug it in when constructing their HTTP client. destregistry/ no longer has any proxy-specific code. The wrapper, the Envoy flag table, the CONNECT-response callback, and all four test files move with the sentinels. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: gofmt baseprovider Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(webhook-proxy): clarify Pub/Sub redelivery runway with OSS defaults Replace the "set 5s/600s/10 attempts for ~30min runway" recommendation with the actual behavior of the default provisioning in internal/mqinfra/gcppubsub.go (10s/120s/6 attempts ~ 5min) and note that operators expecting longer proxy outages should tune MinRetryBackoff/MaxRetryBackoff/RetryLimit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(destwebhook): map Envoy DF (DNS failure), not DC DC (Downstream Connection Termination) is the wrong flag — it means the client closed the connection, not DNS resolution. The actual flag emitted by Envoy's dynamic_forward_proxy when a host fails to resolve is DF (DNS Failure). Verified against a local Envoy reference proxy (build/dev/envoy/envoy.yaml): nonexistent.invalid → DF → dns_error. Also adds a local-dev Envoy service (build/dev/compose.yml + envoy.yaml) matching the documented config — `%RESPONSE_FLAGS%` on RouteConfiguration with OVERWRITE_IF_EXISTS_OR_ADD — so the QA suite at qa/suites/outpost/ webhook-proxy.md can be run against a real proxy locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(destwebhook): expand Envoy flag mapping + capture diagnostics Address review feedback that the recorded error code is too coarse and gives operators nothing to diagnose with. Mapping: align with ClassifyNetworkError vocabulary so customers see the same codes whether or not a proxy is in path. - UC, UR, LR → connection_reset (split out of connection_refused) - UPE, DPE → protocol_error (new bucket; previously network_error) - UMSDR → timeout - network_error stays as the catch-all; operator-visible signal that a new flag has shown up and the table should be expanded. Diagnostics: capture raw x-envoy-response-flags and the new x-envoy-response-code-details (stage{reason}) on ErrProxyDestination and attach them to the publish-attempt error payload as proxy_flag / proxy_details. Logged operator-side; never written to the customer- visible attempt response_data — same model EG uses. Ref Envoy config emits both headers with OVERWRITE_IF_EXISTS_OR_ADD so destinations can't spoof them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(destwebhook): generic Diagnostics map on ErrProxyDestination Replace Flag/Details fields with a free-form Diagnostics map[string]string. The previous shape baked Envoy-specific terminology into a struct that classifies errors from any forward proxy — even though only Envoy populates the fields today. Generic map keeps the contract honest: whoever populates a key owns its naming (envoy_flag, envoy_details for Envoy; future Squid/HAProxy paths would add their own). Error() sorts keys for deterministic log output. httphelper splats the map into the publish-attempt Data payload. Behavior unchanged — same log strings, same customer-invisible surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b87b8c commit 121bd95

15 files changed

Lines changed: 1329 additions & 79 deletions

File tree

build/dev/compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ services:
4242
environment:
4343
SERVICE: log
4444

45+
envoy:
46+
image: envoyproxy/envoy:v1.31-latest
47+
volumes:
48+
- ./envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro
49+
command: ["envoy", "-c", "/etc/envoy/envoy.yaml", "--log-level", "info"]
50+
4551
portal:
4652
build:
4753
context: ../../

build/dev/envoy/envoy.yaml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
admin:
2+
address:
3+
socket_address: { address: 0.0.0.0, port_value: 9901 }
4+
5+
static_resources:
6+
listeners:
7+
- name: forward_proxy
8+
address:
9+
socket_address: { address: 0.0.0.0, port_value: 10000 }
10+
filter_chains:
11+
- filters:
12+
- name: envoy.filters.network.http_connection_manager
13+
typed_config:
14+
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
15+
stat_prefix: forward_proxy
16+
codec_type: AUTO
17+
access_log:
18+
- name: envoy.access_loggers.stdout
19+
typed_config:
20+
"@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
21+
http_filters:
22+
- name: envoy.filters.http.dynamic_forward_proxy
23+
typed_config:
24+
"@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.FilterConfig
25+
dns_cache_config:
26+
name: dfp_cache
27+
dns_lookup_family: V4_ONLY
28+
- name: envoy.filters.http.router
29+
typed_config:
30+
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
31+
upgrade_configs:
32+
- upgrade_type: CONNECT
33+
route_config:
34+
name: proxy_route
35+
response_headers_to_add:
36+
- header:
37+
key: "x-envoy-response-flags"
38+
value: "%RESPONSE_FLAGS%"
39+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
40+
- header:
41+
key: "x-envoy-response-code-details"
42+
value: "%RESPONSE_CODE_DETAILS%"
43+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
44+
virtual_hosts:
45+
- name: proxy
46+
domains: ["*"]
47+
routes:
48+
- match: { connect_matcher: {} }
49+
route:
50+
cluster: dfp_cluster
51+
upgrade_configs:
52+
- upgrade_type: CONNECT
53+
connect_config: {}
54+
- match: { prefix: "/" }
55+
route: { cluster: dfp_cluster }
56+
57+
clusters:
58+
- name: dfp_cluster
59+
lb_policy: CLUSTER_PROVIDED
60+
cluster_type:
61+
name: envoy.clusters.dynamic_forward_proxy
62+
typed_config:
63+
"@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig
64+
dns_cache_config:
65+
name: dfp_cache
66+
dns_lookup_family: V4_ONLY
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
---
2+
title: "Webhook Forward Proxy"
3+
description: "Route outgoing webhook deliveries through an HTTP forward proxy for static-IP egress or network isolation."
4+
---
5+
6+
Outpost can route outgoing webhook traffic through an HTTP forward proxy. Common reasons:
7+
8+
- **Static-IP egress** — destinations that allowlist a specific source IP
9+
- **Network isolation** — keep delivery workers off the public internet
10+
- **Centralized egress policy** — single chokepoint for outbound traffic
11+
12+
Proxy support is operator-configured and applies to every webhook destination served by the deployment.
13+
14+
## Configuration
15+
16+
| Env var | Description |
17+
|---------|-------------|
18+
| `DESTINATIONS_WEBHOOK_PROXY_URL` | Proxy URL, e.g. `http://user:pass@proxy.example.com:8080`. Supports basic auth. |
19+
20+
When `DESTINATIONS_WEBHOOK_PROXY_URL` is set, Outpost installs an HTTP proxy on the webhook publisher's transport. HTTPS destinations use the standard `CONNECT` tunneling flow; HTTP destinations are forwarded request-by-request.
21+
22+
## Error handling
23+
24+
Putting a forward proxy in the delivery path introduces a new failure surface that should not be charged to the destination. A proxy-auth misconfiguration or a proxy outage isn't the destination's fault, and recording it as a failed delivery attempt burns retry budget on a problem the destination cannot resolve.
25+
26+
Outpost distinguishes **proxy infrastructure failures** from **destination failures** (including destination failures that the proxy merely *reports* on the destination's behalf) and applies the right behavior to each. The base behavior below applies to any forward proxy; Envoy-specific signals are picked up automatically when present (see [Envoy support](#envoy-support)).
27+
28+
### General behavior
29+
30+
| Scenario | Attribution | Behavior |
31+
|---|---|---|
32+
| Proxy returns 407 / 401 / 403 on `CONNECT` | Infra | **Nack** — operator misconfiguration of proxy credentials |
33+
| Proxy unreachable (TCP dial to proxy fails) | Infra | **Nack** — proxy infrastructure outage |
34+
| `CONNECT` succeeds, destination returns real 4xx/5xx (HTTPS) | Destination | Record attempt with the actual status code |
35+
| `CONNECT` succeeds, destination TLS handshake fails (HTTPS) | Destination | Record attempt (`tls_error`) |
36+
| `CONNECT` succeeds, destination times out | Destination | Record attempt (`timeout`) |
37+
| Proxy returns other 5xx on `CONNECT` (cannot reach destination) | Destination | Record attempt (`connection_refused`) |
38+
| Real upstream response passed through (plain-HTTP) | Destination | Record as today |
39+
| Proxy itself overloaded or misbehaving (rare) | Destination (conservative) | Record attempt (`network_error`) — never nack on speculation |
40+
41+
**Key principle:** when the proxy reports a failure that originated at the destination (DNS, connect refused, upstream timeout), the customer still sees it as a destination failure. Outpost rewrites the message so the response data is destination-attributed, not proxy-attributed. Nacking is reserved for cases where the proxy itself is the proximate cause.
42+
43+
**HTTPS responses are byte-transparent.** Once the `CONNECT` tunnel opens, TLS runs end-to-end between Outpost and the destination; the forward proxy can no longer read or modify response bytes. Outpost therefore does not inspect or sanitize HTTPS response payloads — they are recorded as the destination sent them. Proxy-originated HTTPS failures (auth, unreachable, can't connect upstream) all happen at `CONNECT` time and are handled before the tunnel exists.
44+
45+
Response-body and response-header sanitization on the plain-HTTP forwarding path is best-effort and depends on the proxy implementation being recognized. For an arbitrary forward proxy, Outpost can rewrite error messages but cannot reliably strip proxy-identifying response content. Sanitization is currently complete only for Envoy — see [Envoy support](#envoy-support).
46+
47+
## Envoy support
48+
49+
When the proxy is [Envoy](https://www.envoyproxy.io/), Outpost picks up Envoy-specific signals automatically — no configuration toggle required. These are additive on top of the general behavior above.
50+
51+
### Additional behaviors
52+
53+
Envoy-specific handling fires on two surfaces: the `CONNECT` response (HTTPS, where the proxy's response is visible to Outpost before the tunnel opens), and proxied plain-HTTP responses (where the proxy is in the byte path on the way back). Responses that arrived through an established `CONNECT` tunnel are not inspected — see the byte-transparency note above.
54+
55+
| Scenario | Signal | Attribution | Behavior |
56+
|---|---|---|---|
57+
| Envoy `CONNECT` failure with response-flag header | `x-envoy-response-flags` on the `CONNECT` response | Destination | Record attempt with code refined from the flag (e.g. `DF` → `dns_error`, `UT` → `timeout`) instead of the generic `connection_refused` |
58+
| Envoy synthesizes 5xx response (plain-HTTP path) | `x-envoy-response-flags: UF` / `UC` / etc. on the response | Destination | Record attempt with code mapped from the flag; response body is dropped |
59+
| Real upstream response passed through (plain-HTTP) | `x-envoy-response-flags: -` or empty | Destination | Record as today |
60+
| Successful plain-HTTP response served via Envoy | `x-envoy-*` and `server: envoy` headers present | Destination | Record; headers stripped before storage |
61+
| HTTPS response (any status, any headers, post-`CONNECT`) | — | Destination | Pass-through unchanged — bytes never touched the forward proxy |
62+
63+
Two Envoy-specific behaviors are layered on top, both scoped to surfaces where the forward proxy actually contributed to the bytes:
64+
65+
- **Response-flag mapping** — `x-envoy-response-flags`, when present and non-empty, refines the destination error code. Mapping aligns with Outpost's non-proxy error vocabulary (`ClassifyNetworkError`), so customers see the same codes whether or not a proxy is in path:
66+
67+
| Envoy flag | Outpost code | Meaning |
68+
|---|---|---|
69+
| `UF`, `UH`, `LH` | `connection_refused` | TCP dial failed / no healthy upstream / failed health check |
70+
| `UC`, `UR`, `LR` | `connection_reset` | Established connection dropped / remote or local reset |
71+
| `UT`, `SI`, `DT`, `UMSDR` | `timeout` | Upstream / stream-idle / duration / max-stream timeout |
72+
| `DF` | `dns_error` | DNS resolution failure (`dynamic_forward_proxy`) |
73+
| `NR`, `NC` | `network_unreachable` | No route / no cluster |
74+
| `UPE`, `DPE` | `protocol_error` | Upstream / downstream protocol error |
75+
| (any other flag) | `network_error` | Unmapped — operator-visible signal to expand the table |
76+
77+
Applies to `CONNECT` responses and plain-HTTP responses, not to bytes returned through an HTTPS tunnel.
78+
79+
- **Operator diagnostics** — when a flag fires, the raw `x-envoy-response-flags` value and `x-envoy-response-code-details` (the `stage{reason}` string, e.g. `upstream_reset_before_response_started{connection_timeout}`) are written into a generic proxy-diagnostics map on the error as `envoy_flag` / `envoy_details`. These surface in the underlying error message and on the publish-attempt error payload, visible in `consumer handler error` logs — but never written to the customer-visible attempt `response_data`, which mirrors a normal network failure (no status, no body). The map is intentionally untyped so other proxies (Squid, HAProxy) can populate their own keys without colliding. To enable this, the Envoy ref config emits the details header alongside the flag header.
80+
81+
- **Header and body sanitization** — `x-envoy-*` and `server: envoy` headers are stripped from plain-HTTP responses; Envoy-synthesized plain-HTTP response bodies are replaced with a normalized message that does not leak Envoy. HTTPS responses are not sanitized.
82+
83+
Support for other forward proxies (Squid, HAProxy, nginx, ...) can be added the same way — by detecting proxy-specific response signals and mapping them to destination error codes. None are currently implemented.
84+
85+
#### Limitation: plain-HTTP destinations that are themselves behind Envoy
86+
87+
If a destination is reached over plain HTTP and the destination itself sits behind its own Envoy edge, the destination's `x-envoy-*` headers (e.g. `x-envoy-upstream-service-time`, `server: envoy`) pass through the forward proxy and Outpost strips them. The destination's `x-envoy-response-flags` is overwritten by the forward Envoy's value, so attribution is still correct — the customer never sees a destination failure misattributed to the proxy — but some destination-side observability headers are lost on this code path.
88+
89+
This does not affect HTTPS destinations: HTTPS responses are byte-transparent (see above), so any `x-envoy-*` headers from the destination's Envoy reach Outpost untouched.
90+
91+
### Required Envoy configuration
92+
93+
For Outpost to reliably distinguish Envoy-synthesized responses from real upstream responses, Envoy must emit its response flags as a response header. The response-code-details header is optional but recommended — without it, Outpost still classifies via the flag, but operators lose the precise stage/reason in logs. Add to the route configuration (Envoy rejects these fields on the HTTP connection manager — they belong on `RouteConfiguration`):
94+
95+
```yaml
96+
route_config:
97+
response_headers_to_add:
98+
- header:
99+
key: "x-envoy-response-flags"
100+
value: "%RESPONSE_FLAGS%"
101+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
102+
- header:
103+
key: "x-envoy-response-code-details"
104+
value: "%RESPONSE_CODE_DETAILS%"
105+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
106+
virtual_hosts:
107+
# ...
108+
```
109+
110+
`OVERWRITE_IF_EXISTS_OR_ADD` is important — it prevents a misbehaving destination from spoofing either header to confuse Outpost's classification or pollute operator diagnostics.
111+
112+
### Minimal reference Envoy
113+
114+
A minimal forward-proxy Envoy listener with response-flag reporting enabled:
115+
116+
```yaml
117+
admin:
118+
address:
119+
socket_address: { address: 127.0.0.1, port_value: 9901 }
120+
121+
static_resources:
122+
listeners:
123+
- name: forward_proxy
124+
address:
125+
socket_address: { address: 0.0.0.0, port_value: 10000 }
126+
filter_chains:
127+
- filters:
128+
- name: envoy.filters.network.http_connection_manager
129+
typed_config:
130+
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
131+
stat_prefix: forward_proxy
132+
codec_type: AUTO
133+
http_filters:
134+
- name: envoy.filters.http.dynamic_forward_proxy
135+
typed_config:
136+
"@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.FilterConfig
137+
dns_cache_config:
138+
name: dfp_cache
139+
dns_lookup_family: V4_ONLY
140+
- name: envoy.filters.http.router
141+
typed_config:
142+
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
143+
upgrade_configs:
144+
- upgrade_type: CONNECT
145+
route_config:
146+
response_headers_to_add:
147+
- header:
148+
key: "x-envoy-response-flags"
149+
value: "%RESPONSE_FLAGS%"
150+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
151+
- header:
152+
key: "x-envoy-response-code-details"
153+
value: "%RESPONSE_CODE_DETAILS%"
154+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
155+
virtual_hosts:
156+
- name: proxy
157+
domains: ["*"]
158+
routes:
159+
- match: { connect_matcher: {} }
160+
route:
161+
cluster: dfp_cluster
162+
upgrade_configs:
163+
- upgrade_type: CONNECT
164+
connect_config: {}
165+
- match: { prefix: "/" }
166+
route: { cluster: dfp_cluster }
167+
168+
clusters:
169+
- name: dfp_cluster
170+
lb_policy: CLUSTER_PROVIDED
171+
cluster_type:
172+
name: envoy.clusters.dynamic_forward_proxy
173+
typed_config:
174+
"@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig
175+
dns_cache_config:
176+
name: dfp_cache
177+
dns_lookup_family: V4_ONLY
178+
```
179+
180+
The example above is a minimal listener. Whether you need proxy authentication and TLS depends on your network topology: if Outpost and the proxy share a private network, neither is strictly required; if the proxy is reachable over the public internet, both are strongly recommended to prevent the proxy being used as an open relay.
181+
182+
## Queue retry behavior
183+
184+
When a proxy infrastructure error is nacked, the underlying message queue redelivers the event. Because nacks only fire for true infra failures (proxy auth or proxy unreachable), the redelivery rate is bounded by the proxy outage duration, not by destination behavior.
185+
186+
Outpost's default GCP Pub/Sub provisioning (`internal/mqinfra/gcppubsub.go`) uses `retryPolicy: {minimumBackoff: 10s, maximumBackoff: 120s}` with `maxDeliveryAttempts: 6` (default `RetryLimit` + 1). That gives roughly **5 minutes of redelivery runway** before a nacked message lands in the dead-letter topic. A proxy outage shorter than this window is transparent to the destination; longer outages require manual replay from the DLQ.
187+
188+
If you expect longer proxy outages, raise `MinRetryBackoff` / `MaxRetryBackoff` (config) and `RetryLimit` (policy) so the redelivery window covers your worst-case outage duration. RabbitMQ / SQS / Kafka have equivalent knobs on their delivery queues.

docs/content/nav.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
},
4949
{ "slug": "features/tenant-user-portal", "title": "Tenant Portal" },
5050
{ "slug": "features/metrics", "title": "Metrics" },
51-
{ "slug": "features/opentelemetry", "title": "OpenTelemetry" }
51+
{ "slug": "features/opentelemetry", "title": "OpenTelemetry" },
52+
{ "slug": "features/webhook-proxy", "title": "Webhook Forward Proxy" }
5253
]
5354
]
5455
},

internal/destregistry/baseprovider.go

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ package destregistry
33
import (
44
"context"
55
"fmt"
6-
"net/http"
7-
"net/url"
86
"regexp"
97
"strconv"
108
"strings"
11-
"time"
129

1310
"github.com/hookdeck/outpost/internal/destregistry/metadata"
1411
"github.com/hookdeck/outpost/internal/models"
@@ -199,55 +196,3 @@ func validateField(field metadata.FieldSchema, value string, path string) *Valid
199196
func (p *BaseProvider) Preprocess(newDestination *models.Destination, originalDestination *models.Destination, opts *PreprocessDestinationOpts) error {
200197
return nil
201198
}
202-
203-
type HTTPClientConfig struct {
204-
Timeout *time.Duration
205-
UserAgent *string
206-
ProxyURL *string
207-
}
208-
209-
func (p *BaseProvider) MakeHTTPClient(config HTTPClientConfig) (*http.Client, error) {
210-
client := &http.Client{}
211-
212-
if config.Timeout != nil {
213-
client.Timeout = *config.Timeout
214-
}
215-
216-
// Configure transport with proxy and/or user agent if needed
217-
if config.ProxyURL != nil || config.UserAgent != nil {
218-
// Start with default transport settings
219-
transport := http.DefaultTransport.(*http.Transport).Clone()
220-
221-
// Configure proxy if provided
222-
if config.ProxyURL != nil && *config.ProxyURL != "" {
223-
proxyURLParsed, err := url.Parse(*config.ProxyURL)
224-
if err != nil {
225-
return nil, fmt.Errorf("invalid proxy URL: %w", err)
226-
}
227-
transport.Proxy = http.ProxyURL(proxyURLParsed)
228-
}
229-
230-
// Wrap transport with user agent if needed
231-
if config.UserAgent != nil {
232-
client.Transport = &userAgentTransport{
233-
userAgent: *config.UserAgent,
234-
transport: transport,
235-
}
236-
} else {
237-
client.Transport = transport
238-
}
239-
}
240-
241-
return client, nil
242-
}
243-
244-
// userAgentTransport wraps an http.RoundTripper to inject a User-Agent header
245-
type userAgentTransport struct {
246-
userAgent string
247-
transport http.RoundTripper
248-
}
249-
250-
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
251-
req.Header.Set("User-Agent", t.userAgent)
252-
return t.transport.RoundTrip(req)
253-
}

0 commit comments

Comments
 (0)