A call to a vendor fails, and the on-call engineer opens the vendor’s status page. It is green. An hour later somebody finds the egress rule that refused the host, added the day before. The message the caller logged said the connection failed, which is what a vendor outage says too.
What you get
You will end up with a catch block that names which of three things stopped a call: your policy, the network, or the vendor. A test proves a denied host fails before any packet leaves. This is for you if your calls pass an egress policy and incident notes say “vendor down” too often.
Short answer
Make the policy denial an error the caller can branch on, not a log line somebody has to find.
station refuses a host outside its policy.hosts allowlist with a StationError whose code is
station_host_allow, before any connection opens. A connection failure carries a socket code
on err.cause, and a vendor failure carries an HTTP status. Envoy and Squid record their
verdict in an access log, so theirs is a search away rather than a branch away.
You will need
Node 22 or later, @voxgig/station 0.1.0, and an SDK generated by sdkgen for station to bind
to. The code directory carries one, built once from api.json by generate.sh and checked in
as compiled JavaScript. Verified 2026-09-25
against Node 22.22.2, @voxgig/station 0.1.0, @voxgig/create-sdkgen 0.28.0 and @voxgig/sdkgen
4.28.0. Envoy and Squid did not run here; the proxy in the demo is a loopback stand-in.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Envoy access logs | Envoy is already your egress proxy and you own its configuration | The default format logs a denial as a bare 403, the policy name appears only once %RESPONSE_CODE_DETAILS% is added, and somebody still has to find the line | The caller has to branch on the denial in code rather than a person reading a log |
| Squid access log | Squid is your forward proxy and its access rules are the policy | TCP_DENIED/403 names the outcome and not the rule, the caller gets a 403 page it never asked for, and the reason lives in a file on the proxy | You need the rule that fired, or a code in the caller’s error |
| station error codes and event ring | Calls leave through sdkgen-generated SDKs in one process and you want the denial in the catch block | A library per language in every service, a policy per repository, and no wire enforcement: a call that bypasses the SDK bypasses the policy | Enforcement must hold for code you do not build, or for a language station has no port for |
Voxgig maintains station. It is one of three options here and the only one that runs on this page; Envoy and Squid are compared from their documentation.
The split is where the verdict lands. A proxy decides on the wire and writes the reason to its own log, so the caller sees a status and the reason is a search away. station decides inside the process that made the call, so the reason is the error itself, and the policy holds only for calls that go through it. Run both where you can: the proxy for enforcement, the library for the message.
Make the denial an error, not a line
The policy is a hosts allowlist on the SDK’s block in station.json. Here it sits in code,
because the demo’s vendor listens on a port chosen at start.
function open({ base, hosts }) {
const station = new Station({
proxy: 'off',
config: { station: 1, profiles: { default: { sdk: { meterco: { base, policy: { hosts } } } } } },
})
return { station, sdk: station.connect(MetercoSDK, {}) }
}
connect builds the SDK with station’s adapter wrapped around its transport, so every request
crosses one seam. There station compares the request’s host name with hosts. When the host
is absent it returns a StationError instead of opening a connection, and the SDK rejects the
operation with it. err.code is station_host_allow, and the message names the host and the
plugin.1 That is the whole difference from a proxy: the catch block sees a code.
export function classify(err) {
// station refuses the call before any connection is made, and says so
// with a code the caller can branch on rather than a message to parse.
if (err?.code === 'station_host_allow') {
return { kind: 'policy', detail: 'denied by the hosts policy, nothing left the process' }
}
// fetch fails with a TypeError whose cause carries the socket error.
const cause = err?.cause?.code
if (cause) return { kind: 'network', detail: cause }
const status = err?.status ?? err?.result?.status
const headers = err?.result?.headers ?? {}
const via = String(headers.via ?? '')
if (status === 502 && via.includes(OUR_PROXY) && err?.result?.body?.by === OUR_PROXY) {
// A 502 is a gateway talking about its upstream (RFC 9110 section 15.6.3). Our
// proxy's Via is on everything it relays, the vendor's own 502 included, so the
// body has to be ours as well before the gateway is ours and its log has the reason.
return { kind: 'network', detail: `502 generated by ${via}, read the proxy log` }
}
if (status === 401 || status === 403) return { kind: 'credential', detail: `${status} from the vendor` }
if (typeof status === 'number' && status >= 500) {
const after = headers['retry-after']
return { kind: 'vendor', detail: `${status} from the vendor${after ? `, Retry-After ${after}` : ''}` }
}
return { kind: 'unknown', detail: err?.message ?? String(err) }
}
Three branches, in the order they can be told apart. A station_host_allow never reached the
network. A TypeError from fetch carries the socket’s error code on err.cause, which is
where ECONNREFUSED and ENOTFOUND live. Everything else came back over HTTP with a status,
and a 502 that carries your proxy’s Via and your proxy’s own body is your proxy speaking, not
the vendor.2 The Via alone proves nothing, because the proxy adds it to everything it
relays, the vendor’s own 502 included. The SDK keeps the response on err.result, headers and
body included, so no second request is needed.
Run the five failures side by side
One call, five ways. The vendor and the proxy are loopback stand-ins from stands.mjs. The
proxy relays, appends itself to Via, and answers 502 itself, with a body that names it, when
its target refuses the connection. It writes one line to its own log either way.
node demo.mjs
@voxgig/station 0.1.0, solo mode, against loopback stand-ins
1. host not in the policy
caller caught station_host_allow: policy (denied by the hosts policy, nothing left the process)
station event error station_host_allow: station_host_allow: egress to "127.0.0.1" denied by the hosts policy of plugin "meterco"
proxy log no line, the proxy was never reached
2. vendor healthy
caller caught ok, 1 meter
station event http 200
proxy log egress-proxy 200 relayed GET /meters
3. vendor in maintenance
caller caught request_status: vendor (503 from the vendor, Retry-After 30)
station event http 503
proxy log egress-proxy 503 relayed GET /meters
4. proxy cannot reach the vendor
caller caught request_status: network (502 generated by 1.1 egress-proxy, read the proxy log)
station event http 502
proxy log egress-proxy 502 upstream_connection_failure(ECONNREFUSED) GET /meters
5. nothing listening at all
caller caught TypeError: network (ECONNREFUSED)
station event error (no code): fetch failed
proxy log no proxy in the path
Read scenario 1 against scenario 5. Both are failures the caller has to handle, and only one of
them is the caller’s own doing. The denial carries a code and the proxy has no line for it,
because no request was made. The refused connection carries no station code at all: the ring
records an http event with status 0 and then an error with no code.
Scenarios 3 and 4 are the pair that costs people an hour. The SDK reports both as
request_status, the ring records a 503 and a 502, and only the proxy log writes the
difference down. relayed means the vendor answered; upstream_connection_failure means the
proxy never got an answer to relay.
Read the proxy’s log for the verdict
Envoy’s RBAC filter answers a denied request with a 403 and puts
rbac_access_denied_matched_policy[policy_name] into %RESPONSE_CODE_DETAILS%, which
its documentation
says exists to distinguish a deny by the filter from one by the upstream backend. The default
access log format does not include that operator,3 so a stock log shows 403 and nothing
more. Add the operator. The flags column separates the network from the vendor: the
substitution formatter reference
lists UF with a 503 as an upstream connection failure, and a 503 with no flag is the
vendor’s own.
Squid writes one line per request with a result code built from tags. TCP_DENIED/403 is an
access rule refusal. ABORTED on a miss is Squid failing to talk to the origin, and since
version 6 that includes a refused TCP connection.4 None of those tags reaches the caller,
who gets a 403 error page from Squid for a denial and a 5xx for the rest. The verdict exists,
in the proxy’s vocabulary, on the proxy, and the person who needs it is holding a stack trace
from a different machine.
Check it worked
The test worth having is the one that proves the denied call never left the process, and that the proxy’s 502 and the vendor’s 503 are told apart without a second request.
await assert.rejects(() => sdk.Meter().list(), (err) => {
assert.equal(err.name, 'StationError')
assert.equal(err.code, 'station_host_allow')
assert.equal(classify(err).kind, 'policy')
return true
})
assert.equal(v.state.requests, 0, 'the vendor saw nothing')
const errored = tapped.find((ev) => ev.kind === 'error')
assert.equal(errored.err.code, 'station_host_allow', 'a live tap sees the same code')
node --test triage.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
The tap is the third place the code lands; the thrown error and the ring are the other two. A
tap registered at startup that pages on station_host_allow is an alert on a policy mistake.
When it goes wrong
A 502 sends you to the vendor’s status page. A gateway uses 502 for its own failure to reach
the upstream, and the vendor’s edge does the same one hop further on. Read the Via header
and the body before the status page, and when both are your proxy’s, open your proxy’s log.
Scenario 4 is that case; scenario 3 looks the same from the catch block.
Every call fails with station_host_allow after a deploy. The vendor moved you to a regional
host, or the base URL changed, and the allowlist still names the previous host. The message
carries the refused host, so the fix is one entry in hosts.
Nothing is ever denied. The sdk key in the profile does not match the SDK’s slug, so no
block applies and the call goes out with no policy on it. station.close() emits a warning
event for every profile key that matched no registered plugin; assert on it in a test.
When not to do this
Do not adopt station to get one error code if your calls do not leave through sdkgen-generated
SDKs. The code arrives with a library in every service and a policy per repository, and a raw
fetch beside the SDK is outside the policy.
Do not let the in-process check stand in for enforcement. A process that never loads station reaches the vendor, so keep the network rule and the proxy from the egress proxy page, and use the library for the message.
Do not parse the message. The text egress to "host" denied is prose and can change between
versions; station_host_allow is the contract, and the block mode (policy.mode: block)
uses the same code on purpose.
Do not classify a 5xx from the status alone. A 502 with no Via from your proxy is somebody
else’s gateway, and the vendor’s 503 with a Retry-After is a schedule, not an outage. Do not
retry a policy denial either: the same host gets the same answer until the policy changes.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, @voxgig/station 0.1.0, @voxgig/create-sdkgen 0.28.0
and @voxgig/sdkgen 4.28.0. Every output block is what the command preceding it printed. The SDK
under sdk/ is the compiled output of generate.sh from that run, checked in so the demo runs
offline; the generation is not repeated on each check. station-view is named in the branch
plan and is not published on npm, so the event ring and tap stand where a viewer would.
Footnotes
-
The catalog is one file,
src/error.tsin the package. Its codes follow a grammar that section 14 of the station design document calls the SDKs’ house grammar: subject, then condition, with absence writtenno_<thing>and a gate written_allow. So the host check isstation_host_allow, and the block mode, which the design gave no code of its own, borrows it with a message naming the mode. A grammar with twenty-nine codes and one deliberate reuse is a small catalog, and small catalogs are the ones people read. ↩︎ Back to text -
RFC 9110 gives 502 to a gateway or proxy that received an invalid response from the server it was forwarding to. It gives 503 to a server unable to handle the request for a temporary reason, and adds that an overloaded server might refuse the connection instead. So a refused connection, a 502 and a 503 can all report one outage, seen from three distances. The Via field exists so the distances can be counted: every intermediary adds itself, like the Received line in email. ↩︎ Back to text
-
The default format carries
%RESPONSE_CODE%and%RESPONSE_FLAGS%, then bytes, duration and a handful of headers. The operator it leaves out,%RESPONSE_CODE_DETAILS%, is described in the substitution formatter reference as the field that says who set the response code, the upstream or Envoy, and why. That is the exact question this page is about, answered by an operator the default format does not print. ↩︎ Back to text -
Squid’s log documentation records that before version 6 the
ABORTEDtag was mostly seen when the client closed its connection early. From version 6 it also appears when Squid cannot reach an origin server, a refused TCP connection included. The tag alone cannot tell you which of its two meanings applies, because the log line does not say which Squid wrote it. ↩︎ Back to text