How-to › Operate integrations in production

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.

Audience
Platform team
Level
intermediate
Topic
Control outbound integrations from one place
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Envoy access logsEnvoy is already your egress proxy and you own its configurationThe 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 lineThe caller has to branch on the denial in code rather than a person reading a log
Squid access logSquid is your forward proxy and its access rules are the policyTCP_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 proxyYou need the rule that fired, or a code in the caller’s error
station error codes and event ringCalls leave through sdkgen-generated SDKs in one process and you want the denial in the catch blockA library per language in every service, a policy per repository, and no wire enforcement: a call that bypasses the SDK bypasses the policyEnforcement 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.

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

  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 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. ↩︎ Back to text

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

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

  4. Squid’s log documentation 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.