Skip to content

Every outbound call goes through one door, and unmapped errors carry an id - #118

Merged
unitypark merged 1 commit into
mainfrom
fix/the-boundary-sweep
Aug 17, 2026
Merged

Every outbound call goes through one door, and unmapped errors carry an id#118
unitypark merged 1 commit into
mainfrom
fix/the-boundary-sweep

Conversation

@unitypark

Copy link
Copy Markdown
Owner

Three failures in a row reached users as Internal server error or as a parser
naming a doctype. Each was fixed where it was found, and each was found by
somebody hitting it. This is the sweep for the rest of the class.

Stacked on #117 — review that first; this PR's diff is only the sweep.

The class, precisely

Failure What it really is Reaches the user as
TypeError: fetch failed The request never left the machine Internal server error
SyntaxError: Unexpected token '<' A 2xx whose body is a web page that string, verbatim

Neither is an HttpException, so Nest's default filter flattens both.

The audit

Every fetch, res.json(), JSON.parse and new URL in apps/api/src:

Already guarded — left alone (7). anthropic.service.ts (schema parse, in
a try with a real message), claude-code.provider.ts ×2 (envelope parse, same),
jira.adapter.ts describeJiraError, runs.service.ts (LISTEN payload,
deliberately swallowed), github.controller.ts webhook body → 400,
github-app.service.ts isPubliclyReachable, gitlab-webhook.service.ts
hostOf.

Unguarded and user-reachable — fixed (4 files, 6 sites).

  • Jira had the same triple GitLab did: no URL normalization, no transport
    guard, no JSON guard. Because connectTracker catches everything, a
    schemeless site URL was reported as "Jira rejected that credential"
    blaming the one thing that was fine. Wizard step 4, so the next thing you'd
    have hit.
  • GitHub App token minting — bare fetch + bare res.json(), reached from
    every repository listing, onboarding run and build.
  • The manifest exchange — same, mid-browser-redirect, where a 500 is a
    blank page at the end of a flow you cannot restart without a new code. Wrapped
    as BadRequestException for that reason.
  • The embeddings provider — both implementations. The default points at a
    local Ollama that is very often not running: the likeliest transport failure
    in the codebase, and the one whose bare TypeError says least.
  • GitLab's tree pagination had its own fetch that the earlier fix missed
    entirely — exactly the kind of second call site a per-symptom fix leaves
    behind.

One door, and grep as the enforcement

common/http-failures.ts owns fetchOrExplain, readJsonOrExplain,
describeTransportFailure, describeNonJsonBody and the URL normalizer (which
was in vcs.types.ts and never was VCS-specific — every integration takes a
host somebody configured).

A test walks apps/api/src and asserts that module is the only file calling
fetch directly.
A new integration that calls fetch itself re-opens the
class, and this is what says so at review time rather than after a bug report.

Two invariants fell out and are now pinned:

  • An adapter throws its own typed error and nothing else (VcsError,
    JiraError); anything untyped escaping one is the bug.
  • 204 and an empty body are not broken bodies. Parsing '' fails exactly
    the way a login page does, so every site that forgot reported a portal on a
    successful branch deletion. Handled centrally now.

The safety net

There was no exception filter at all. UnhandledExceptionFilter still
answers 500 for an unmapped error — the right status for a bug nobody typed a
message for — but with a short reference id that is also in the log beside the
stack, and the error's class name, since TypeError and SyntaxError are
precisely the two that mean an outbound call failed in a way nobody wrapped.

500 · Something failed inside specd and no handler had a better answer
      (TypeError). Reference a3f2c1 — the full error is in the API log
      under that id.

HttpExceptions pass through untouched; a 400 that already says what is wrong
is doing its job.

This is the part that ends the pattern. It does not prevent the next one — it
makes it cost one message instead of a session.

Verify

pnpm typecheck && pnpm test (43 API files), pnpm build, pnpm site:check.
12 new tests on the shared module, including the grep rule. knowledge/conventions.md
carries the convention.

For the reviewer

  • Behaviour changes for callers that stubbed fetch. Reading the body as
    text rather than res.json() means a stub supplying only json() now returns
    undefined. Three test files were updated; a real server is unaffected, but
    it's the thing to look for if something downstream misbehaves.
  • The filter is untested. It needs an HTTP round trip to exercise
    meaningfully, and the suite has no app-level harness. The logic is small and
    the failure mode is cosmetic, but it is asserted by reading, not by running.

…an id

Three failures in a row reached users as "Internal server error" or as a parser
naming a doctype, each fixed where it was found and each found by somebody
hitting it. This is the sweep for the rest of the class.

The class is precise: a `TypeError` from a request that never left, or a
`SyntaxError` from a 2xx whose body is a web page. Neither is an
HttpException, so Nest's default filter reduces both to a sentence that tells
nobody anything about the VPN, the certificate or the SSO portal it always
turns out to be.

`common/http-failures.ts` now owns both, plus the URL normalizer that was
sitting in vcs.types (it was never VCS-specific — every integration takes a
host somebody configured). Everything outbound routes through
`fetchOrExplain`/`readJsonOrExplain`, and a test walks apps/api asserting that
module is the only file calling fetch directly, because grep is the only
enforcement that survives the next integration.

Four sites were unguarded and reachable by a user. Jira had the same triple
GitLab did — no URL normalization, no transport guard, no JSON guard — and
`connectTracker` catching everything meant a schemeless site URL was reported
as "Jira rejected that credential", blaming the one thing that was fine. The
GitHub App's token minting and its manifest exchange were bare on both counts.
So was the embeddings provider, whose default points at a local Ollama that is
very often not running — the likeliest transport failure in the codebase and
the one whose TypeError says least. GitLab's tree pagination had its own fetch
that the earlier fix had missed entirely.

Seven other sites were already guarded and are left alone; the audit is in the
PR rather than in more code.

Last, the safety net: there was no exception filter at all. An unmapped error
still answers 500 — that is the right status for a bug nobody typed a message
for — but now with a short reference that is also in the log beside the stack,
and the error's class name, since TypeError and SyntaxError are exactly the two
that mean "an outbound call failed in a way nobody wrapped". The next one costs
a message instead of a session.
@unitypark
unitypark deleted the branch main August 17, 2026 17:09
@unitypark unitypark closed this Aug 17, 2026
@unitypark unitypark reopened this Aug 17, 2026
@unitypark
unitypark changed the base branch from fix/an-html-answer-says-so to main August 17, 2026 17:13
@unitypark
unitypark merged commit f01fbee into main Aug 17, 2026
4 checks passed
@unitypark
unitypark deleted the fix/the-boundary-sweep branch August 17, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant