# How to tell a denied outbound call apart from a vendor outage

> Work out in a minute whether your own egress policy, the network, or the vendor stopped a call, by making the denial an error code the caller can branch on.

Source: https://voxgig.com/howto/diagnose-an-outbound-call-that-was-denied

- Audience: platform-team
- Level: intermediate
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log/usage) | 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](https://wiki.squid-cache.org/SquidFaq/SquidLogs) | 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](https://github.com/voxgig/station#readme) | 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.

```js title="demo.mjs"
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.

```js title="triage.mjs"
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.

```bash
node demo.mjs
```

```text output
@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](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/rbac_filter)
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](https://www.envoyproxy.io/docs/envoy/latest/configuration/advanced/substitution_formatter)
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.

```js title="triage.test.mjs"
  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')
```

```bash
node --test triage.test.mjs
```

```text output
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](/howto/route-outbound-calls-through-an-egress-proxy), 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

- [Route outbound API calls through an egress proxy](/howto/route-outbound-calls-through-an-egress-proxy)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Add a circuit breaker to an outbound HTTP client](/howto/add-a-circuit-breaker-to-an-http-client)

## 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.

[^1]: The catalog is one file, `src/error.ts` in the package. Its codes follow a grammar that
section 14 of the [station design document](https://github.com/voxgig/station/blob/main/docs/design/station.md)
calls the SDKs' house grammar: subject, then condition, with absence written `no_<thing>` and a
gate written `_allow`. So the host check is
`station_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.

[^2]: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-502-bad-gateway) gives 502
to a gateway or proxy that received an invalid response from the server it was forwarding to.
It gives [503](https://www.rfc-editor.org/rfc/rfc9110.html#name-503-service-unavailable) 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](https://www.rfc-editor.org/rfc/rfc9110.html#name-via)
field exists so the distances can be counted: every intermediary adds itself, like the Received
line in email.

[^3]: The [default format](https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log/usage)
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](https://www.envoyproxy.io/docs/envoy/latest/configuration/advanced/substitution_formatter)
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.

[^4]: Squid's [log documentation](https://wiki.squid-cache.org/SquidFaq/SquidLogs) records that
before version 6 the `ABORTED` tag 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.