Skip to content

feat(errs): http error classifier - #566

Merged
mnoah1 merged 4 commits into
mainfrom
mnoah1/errs-http-classifier
Aug 11, 2026
Merged

feat(errs): http error classifier#566
mnoah1 merged 4 commits into
mainfrom
mnoah1/errs-http-classifier

Conversation

@mnoah1

@mnoah1 mnoah1 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

platform/errs treats an unclassified error as non-retryable, so every HTTP failure an extension returned took that default. A 502 from a proxy in front of a build API was indistinguishable from "this request is invalid": the message dead-lettered on its first attempt instead of being retried.

Add platform/http.StatusError, a typed error that keeps the status code in the chain, and platform/errs/http, the classifier that reads it. Server-state codes (500, 502, 503, 504, other 5xx, 429, 408) are retryable dependency errors; verdicts on the request (4xx, 3xx, the permanently broken 501 and 505, and a code that was never a response) are not.

*url.Error covers transport failures, including an expired deadline — that means the remote end did not answer in time, so it is attributed to the dependency. Only context.Canceled is declined, so a shutdown stays out of a backend's dependency metrics and platform/errs/generic claims that node. Nothing in the classifier depends on platform/errs/mysql being wired to reach a verdict; a test pins the generic + http wiring a service with no MySQL dependency would use.

The classifier must still be listed before platform/errs/mysql, whose net.Error rule matches *url.Error and would otherwise claim HTTP transport failures. Documented in the errs README next to the wiring example.

StatusError.Error renders at most 1 KiB of the response body. The rendered string lands in the queue's dead-letter record via Reject(ctx, err.Error()), and last_error is a finite column, so an error page from a chatty gateway must not be able to fail that write and leave the message stuck rather than dead-lettered. Body itself is kept whole for callers that want to inspect it.

Convert both build-runner clients — Buildkite and GitHub Actions — to return the typed error, and wire the classifier into the stovepipe server, which runs both. Client tests now assert the code is reachable with errors.As, so a refactor back to fmt.Errorf cannot pass silently. The submitqueue-side clients (changeprovider/github, phabricator/conduit, mergechecker/github) are unchanged: no service wires httperrs on those paths, so nothing would read the code.

Behavior change worth calling out: retried creates can duplicate a build

Creating a build is not idempotent, and a rejected create is now reported with its status code like any other, so a retried 502 can start a second build when the first was already accepted. Stovepipe's build controller has no guard against that — request.State.IsTerminal() is false at the point Trigger runs, and the ErrAlreadyExists tolerance keys on a build ID the retry has not minted yet.

This exposure is not new: a transport failure on the same call is already retryable today through the mysql classifier's net.Error rule, so a connection reset mid-write can already duplicate a build. This change widens it from transport failures to 5xx responses. The trade is deliberate — a request that gives up on a proxy blip strands its queue slot, which is worse — and both clients' create paths now document it. Making create idempotent is follow-up work and is the next branch.

Why

  • Correctly classify various types of HTTP errors as retryable

Test Plan

  • Deploy with corresponding changes internally
  • Monitor DLQ volume in the message queue — retryable failures should stop landing there
  • Watch build creation counts against request counts for the duplicate-build case above, which DLQ volume will not show

@mnoah1
mnoah1 marked this pull request as ready for review August 11, 2026 16:24
@mnoah1
mnoah1 requested review from a team, behinddwalls and sbalabanov as code owners August 11, 2026 16:24

@behinddwalls behinddwalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-documented change. The per-node Classifier contract is respected (direct type assertions, no errors.As), the ordering hazard vs. mysqlerrs is real and correctly documented — I confirmed *url.Error does satisfy net.Error, so mysqlerrs would claim it if listed first — and the tests cover both the node-level and processor-level shapes.

Four things worth resolving, inline, roughly in order of consequence:

  1. Duplicate builds — 5xx on CreateBuild (a non-idempotent POST) is now retryable, and the build controller has no guard that would stop a retry from starting a second Buildkite build.
  2. context.DeadlineExceeded — the deferral to "the generic classifier" doesn't land: generic returns Unknown for it, and only mysqlerrs' net.Error rule catches it. A service wiring generic + http without mysql dead-letters client timeouts.
  3. GH Actions client unconverted — same service, both runners, only one gets the benefit.
  4. No test that the Buildkite client returns *StatusError.

Plus two nits.

Comment thread platform/extension/buildrunner/buildkite/client.go
Comment thread platform/errs/http/http.go Outdated
Comment thread service/stovepipe/server/main.go
Comment thread platform/extension/buildrunner/buildkite/client.go
Comment thread platform/errs/http/http.go
Comment thread platform/http/status.go
@mnoah1
mnoah1 force-pushed the mnoah1/errs-http-classifier branch from d0731dd to 35b345e Compare August 11, 2026 20:15
mnoah1 added 4 commits August 11, 2026 21:21
platform/errs treats an unclassified error as non-retryable, so every HTTP
failure an extension returned took that default. A 502 from a proxy in front of
a build API was indistinguishable from "this request is invalid": the message
dead-lettered on its first attempt instead of being retried.

Add platform/http.StatusError, a typed error that keeps the status code in the
chain, and platform/errs/http, the classifier that reads it. Server-state codes
(500, 502, 503, 504, other 5xx, 429, 408) are retryable dependency errors;
verdicts on the request (4xx, 3xx, and the permanently broken 501 and 505) are
not. *url.Error covers transport failures, except when it wraps our own context
cancellation — that node is left to the generic classifier so shutdowns stay out
of a backend's dependency metrics.

The classifier must be listed before platform/errs/mysql, whose net.Error rule
matches *url.Error and would otherwise claim HTTP transport failures. Documented
in the errs README next to the wiring example.

Convert the Buildkite client to return the typed error and wire the classifier
into the stovepipe server. The GitHub Actions client and the other services
still format their status codes into strings and are unchanged here.
- Stop declining context.DeadlineExceeded. Only context.Canceled is left to the
  generic classifier now. A deadline that elapses mid-request means the remote
  end did not answer in time, so it belongs to the dependency; declining it also
  stranded the node, because generic matches only Canceled and nothing else
  claimed it unless platform/errs/mysql happened to be wired. A processor test
  with just generic + http pins that the classifier stands on its own.
- Bound StatusError.Error to 1 KiB of body. The rendered string reaches the
  queue's dead-letter record through Reject(ctx, err.Error()) and last_error is
  finite, so a large error page could fail that write and leave the message stuck
  instead of dead-lettered. Body is still kept whole.
- Convert the GitHub Actions client's two status paths as well. Stovepipe runs
  both build runners, so leaving one untyped meant a 502 was retried or
  dead-lettered depending only on which runner the queue used.
- Assert with errors.As that both clients return *StatusError and carry the code.
  The old tests only checked that an error came back, so they passed before the
  typed error existed and would keep passing if it were reverted.
- Document that a retried create can duplicate a build, on both clients' create
  paths. Note that codes below 100, including 0, take the non-retryable
  fall-through deliberately, and cover 0 in the table.
The runners now reach for platform/errs and platform/http to mark a create
that may have taken effect as non-retryable, which gazelle has to see.
@mnoah1
mnoah1 force-pushed the mnoah1/errs-http-classifier branch from 88b082c to 468182f Compare August 11, 2026 21:22
@mnoah1
mnoah1 enabled auto-merge August 11, 2026 21:25
@mnoah1
mnoah1 added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 88c8f78 Aug 11, 2026
15 checks passed
@mnoah1
mnoah1 deployed to stack-rebase August 11, 2026 22:44 — with GitHub Actions Active
@behinddwalls
behinddwalls deleted the mnoah1/errs-http-classifier branch August 11, 2026 22:45
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.

2 participants