Skip to content

Commit 88c8f78

Browse files
authored
feat(errs): http error classifier (#566)
## 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
1 parent a60a7b4 commit 88c8f78

19 files changed

Lines changed: 659 additions & 8 deletions

File tree

platform/errs/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Two implementations ship in this package:
5858

5959
## Adding a Backend-Specific Classifier
6060

61-
Backend classifiers live alongside the extension they classify, under `platform/errs/<backend>/`. The canonical examples are `platform/errs/mysql` (MySQL driver errors) and `platform/errs/generic` (transport-agnostic concerns such as `context.Canceled`).
61+
Backend classifiers live alongside the extension they classify, under `platform/errs/<backend>/`. The canonical examples are `platform/errs/mysql` (MySQL driver errors), `platform/errs/http` (rejected status codes and transport failures from clients built on `platform/http`), and `platform/errs/generic` (transport-agnostic concerns such as `context.Canceled`).
6262

6363
A classifier:
6464

@@ -91,17 +91,21 @@ Servers wire each classifier into the consumer's `ErrorProcessor`. Order matters
9191
import (
9292
"github.com/uber/submitqueue/platform/errs"
9393
genericerrs "github.com/uber/submitqueue/platform/errs/generic"
94+
httperrs "github.com/uber/submitqueue/platform/errs/http"
9495
mysqlerrs "github.com/uber/submitqueue/platform/errs/mysql"
9596
)
9697

9798
c := consumer.New(logger, scope, registry,
9899
errs.NewClassifierProcessor(
99100
genericerrs.Classifier,
101+
httperrs.Classifier,
100102
mysqlerrs.Classifier,
101103
),
102104
)
103105
```
104106

107+
`httperrs` precedes `mysqlerrs` for a reason worth knowing before reordering the list: the MySQL classifier treats any `net.Error` as retryable infra, and the `*url.Error` an HTTP client returns satisfies `net.Error`. Whichever runs first claims that node, so with the order reversed an HTTP transport failure is classified as a MySQL one — retryable either way, but no longer attributed to the dependency it came from. This is the cross-extension ambiguity `NewClassifierProcessor` documents as deferred; registration order is the workaround.
108+
105109
Tests follow the same shape: assert per-node behaviour against `Classifier.Classify(node)` directly, and assert end-to-end behaviour by running `errs.NewClassifierProcessor(Classifier).Process(err)` and checking the helpers (`IsRetryable`, `IsUserError`, …) on the result. See `platform/errs/mysql/mysql_test.go` and `platform/errs/generic/generic_test.go`.
106110

107111
## Overriding Classification from a Controller

platform/errs/http/BUILD.bazel

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["http.go"],
6+
importpath = "github.com/uber/submitqueue/platform/errs/http",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/errs:go_default_library",
10+
"//platform/http:go_default_library",
11+
],
12+
)
13+
14+
go_test(
15+
name = "go_default_test",
16+
srcs = ["http_test.go"],
17+
embed = [":go_default_library"],
18+
deps = [
19+
"//platform/errs:go_default_library",
20+
"//platform/errs/generic:go_default_library",
21+
"//platform/errs/mysql:go_default_library",
22+
"//platform/http:go_default_library",
23+
"@com_github_stretchr_testify//assert:go_default_library",
24+
],
25+
)

platform/errs/http/http.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package http provides an errs.Classifier for failures returned by HTTP
16+
// clients: a rejected status code (platform/http.StatusError) and a transport
17+
// failure (*url.Error, which is what http.Client.Do returns).
18+
//
19+
// Wire it into any service whose extensions call an HTTP API — build runners,
20+
// CI gateways, webhook senders. Without it every status code looks the same to
21+
// the pipeline: unclassified, and therefore non-retryable, so a 502 from a proxy
22+
// dead-letters the message on its first attempt rather than being retried.
23+
//
24+
// Order matters when wiring this alongside platform/errs/mysql. The MySQL
25+
// classifier treats any net.Error as retryable infra, and *url.Error satisfies
26+
// net.Error, so it will claim HTTP transport failures if it runs first. List
27+
// this classifier before it to keep those failures attributed to the dependency
28+
// they came from:
29+
//
30+
// errs.NewClassifierProcessor(
31+
// genericerrs.Classifier,
32+
// httperrs.Classifier,
33+
// mysqlerrs.Classifier,
34+
// )
35+
package http
36+
37+
import (
38+
"context"
39+
nethttp "net/http"
40+
"net/url"
41+
42+
"github.com/uber/submitqueue/platform/errs"
43+
phttp "github.com/uber/submitqueue/platform/http"
44+
)
45+
46+
// Classifier implements errs.Classifier for HTTP client failures. It recognises:
47+
//
48+
// - *phttp.StatusError — dispatches on the status code. Codes that describe a
49+
// server-side or overload condition (500, 502, 503, 504, other unassigned
50+
// 5xx, 429, 408) are retryable dependency errors. Codes that describe a
51+
// verdict on the request itself (4xx, 3xx, and the permanently broken 501
52+
// and 505) are non-retryable dependency errors.
53+
// - *url.Error — the wrapper http.Client.Do puts around connection resets, DNS
54+
// failures, TLS errors and timeouts. A retryable dependency error, except
55+
// for our own context cancellation (see Classify).
56+
//
57+
// Everything else returns errs.Unknown so the classifier-processor walk can keep
58+
// looking down the unwrap chain.
59+
//
60+
// The classifier never returns errs.User. A 400 or 403 says the request was
61+
// rejected, not that a person did something wrong; only the controller knows
62+
// whether the request was built from user input. Controllers express that by
63+
// wrapping with errs.NewUserError, which short-circuits pass 1 of the
64+
// classifier-processor before this classifier is consulted.
65+
//
66+
// The classifier is stateless; this package-level singleton is the canonical
67+
// handle. Pass it as one of the variadic classifiers to
68+
// errs.NewClassifierProcessor; the resulting processor is what gets handed to
69+
// consumer.New.
70+
var Classifier errs.Classifier = classifier{}
71+
72+
type classifier struct{}
73+
74+
// Classify inspects a single node. Per the errs.Classifier contract, this must
75+
// not call errors.Is / errors.As — the classifier-processor owns the chain walk.
76+
func (classifier) Classify(err error) errs.Verdict {
77+
if se, ok := err.(*phttp.StatusError); ok {
78+
return classifyStatusCode(se.StatusCode)
79+
}
80+
81+
if ue, ok := err.(*url.Error); ok {
82+
// A cancelled context is ours, not theirs — process shutdown, or a parent
83+
// operation that went away — so decline it and let the generic classifier
84+
// claim context.Canceled as plain retryable infra, keeping shutdowns out
85+
// of this backend's dependency metrics. An expired deadline is theirs:
86+
// the remote end did not answer in time, so it takes the verdict below.
87+
// Declining that one would strand it, since generic matches only Canceled.
88+
if ue.Err == context.Canceled {
89+
return errs.Unknown
90+
}
91+
// Everything else at this layer is a failed exchange with the remote end,
92+
// and none of those shapes says the request was invalid.
93+
return errs.InfraDependencyRetryable
94+
}
95+
96+
return errs.Unknown
97+
}
98+
99+
// classifyStatusCode maps an HTTP status code to a Verdict. The split is whether
100+
// the code describes the state of the server, which can change on its own, or a
101+
// verdict on the request, which replaying only reproduces.
102+
func classifyStatusCode(code int) errs.Verdict {
103+
switch code {
104+
case nethttp.StatusRequestTimeout, // 408 — the server stopped waiting; sending it again is reasonable.
105+
nethttp.StatusTooManyRequests: // 429 — over a rate limit that resets with time.
106+
return errs.InfraDependencyRetryable
107+
108+
case nethttp.StatusNotImplemented, // 501 — the route will not appear because we retried.
109+
nethttp.StatusHTTPVersionNotSupported: // 505 — a client/server mismatch to fix in config.
110+
return errs.InfraDependency
111+
}
112+
113+
// Remaining 5xx: the server reported its own failure. Covers 500, 502, 503
114+
// and 504, the shapes a proxy or overloaded backend produces, plus any
115+
// unassigned or vendor-specific 5xx, which follow the same convention.
116+
if code >= nethttp.StatusInternalServerError {
117+
return errs.InfraDependencyRetryable
118+
}
119+
120+
// 4xx other than the two above, 3xx the client was not configured to follow,
121+
// and anything else a caller chose to reject — including a code that was
122+
// never a response, such as 0: a verdict on the request, or on a malformed
123+
// call. Neither changes on a second attempt.
124+
return errs.InfraDependency
125+
}

platform/errs/http/http_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package http
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"net"
22+
nethttp "net/http"
23+
"net/url"
24+
"testing"
25+
26+
"github.com/stretchr/testify/assert"
27+
"github.com/uber/submitqueue/platform/errs"
28+
genericerrs "github.com/uber/submitqueue/platform/errs/generic"
29+
mysqlerrs "github.com/uber/submitqueue/platform/errs/mysql"
30+
phttp "github.com/uber/submitqueue/platform/http"
31+
)
32+
33+
func TestClassifier_StatusCodes(t *testing.T) {
34+
tests := []struct {
35+
name string
36+
code int
37+
want errs.Verdict
38+
}{
39+
// Server state: changes without us doing anything differently.
40+
{"bad gateway", nethttp.StatusBadGateway, errs.InfraDependencyRetryable},
41+
{"service unavailable", nethttp.StatusServiceUnavailable, errs.InfraDependencyRetryable},
42+
{"gateway timeout", nethttp.StatusGatewayTimeout, errs.InfraDependencyRetryable},
43+
{"internal server error", nethttp.StatusInternalServerError, errs.InfraDependencyRetryable},
44+
{"unassigned 5xx", 599, errs.InfraDependencyRetryable},
45+
{"request timeout", nethttp.StatusRequestTimeout, errs.InfraDependencyRetryable},
46+
{"too many requests", nethttp.StatusTooManyRequests, errs.InfraDependencyRetryable},
47+
48+
// Verdicts on the request: replaying reproduces the same answer.
49+
{"not implemented", nethttp.StatusNotImplemented, errs.InfraDependency},
50+
{"http version not supported", nethttp.StatusHTTPVersionNotSupported, errs.InfraDependency},
51+
{"bad request", nethttp.StatusBadRequest, errs.InfraDependency},
52+
{"unauthorized", nethttp.StatusUnauthorized, errs.InfraDependency},
53+
{"forbidden", nethttp.StatusForbidden, errs.InfraDependency},
54+
{"not found", nethttp.StatusNotFound, errs.InfraDependency},
55+
{"unprocessable entity", nethttp.StatusUnprocessableEntity, errs.InfraDependency},
56+
{"unfollowed redirect", nethttp.StatusFound, errs.InfraDependency},
57+
58+
// Never a response: a caller built this from something else.
59+
{"zero code", 0, errs.InfraDependency},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
assert.Equal(t, tt.want, Classifier.Classify(phttp.NewStatusError(tt.code, nil)))
65+
})
66+
}
67+
}
68+
69+
func TestClassifier_TransportFailures(t *testing.T) {
70+
tests := []struct {
71+
name string
72+
err error
73+
want errs.Verdict
74+
}{
75+
{
76+
name: "connection refused",
77+
err: &url.Error{Op: "Get", URL: "http://api.example", Err: errors.New("connection refused")},
78+
want: errs.InfraDependencyRetryable,
79+
},
80+
{
81+
name: "dns failure",
82+
err: &url.Error{Op: "Get", URL: "http://api.example", Err: &net.DNSError{Err: "no such host"}},
83+
want: errs.InfraDependencyRetryable,
84+
},
85+
{
86+
// Ours, not theirs: declining lets the walk reach context.Canceled,
87+
// where the generic classifier calls it plain retryable infra.
88+
name: "context cancelled",
89+
err: &url.Error{Op: "Get", URL: "http://api.example", Err: context.Canceled},
90+
want: errs.Unknown,
91+
},
92+
{
93+
// Theirs, not ours: the remote end did not answer in time.
94+
name: "context deadline exceeded",
95+
err: &url.Error{Op: "Get", URL: "http://api.example", Err: context.DeadlineExceeded},
96+
want: errs.InfraDependencyRetryable,
97+
},
98+
}
99+
100+
for _, tt := range tests {
101+
t.Run(tt.name, func(t *testing.T) {
102+
assert.Equal(t, tt.want, Classifier.Classify(tt.err))
103+
})
104+
}
105+
}
106+
107+
func TestClassifier_Unknown(t *testing.T) {
108+
tests := []struct {
109+
name string
110+
err error
111+
}{
112+
// Per-node contract: a wrapped StatusError must not match here. The
113+
// classifier-processor walk reaches the inner node and asks again there.
114+
{"wrapped status error", fmt.Errorf("get build x: %w", phttp.NewStatusError(502, nil))},
115+
{"plain error", errors.New("anything")},
116+
{"bare context.Canceled", context.Canceled},
117+
{"nil", nil},
118+
}
119+
120+
for _, tt := range tests {
121+
t.Run(tt.name, func(t *testing.T) {
122+
assert.Equal(t, errs.Unknown, Classifier.Classify(tt.err))
123+
})
124+
}
125+
}
126+
127+
func TestClassifier_AppliedViaProcessor(t *testing.T) {
128+
// The order services wire: generic first, this one before mysqlerrs.
129+
processor := errs.NewClassifierProcessor(genericerrs.Classifier, Classifier, mysqlerrs.Classifier)
130+
131+
t.Run("wrapped 502 becomes a retryable dependency error", func(t *testing.T) {
132+
err := fmt.Errorf("get build org/pipeline/builds/1: %w", phttp.NewStatusError(nethttp.StatusBadGateway, []byte("proxy forward failed")))
133+
out := processor.Process(err)
134+
assert.True(t, errs.IsRetryable(out))
135+
assert.True(t, errs.IsDependencyError(out))
136+
})
137+
138+
t.Run("wrapped 404 stays non-retryable", func(t *testing.T) {
139+
err := fmt.Errorf("get build org/pipeline/builds/1: %w", phttp.NewStatusError(nethttp.StatusNotFound, nil))
140+
out := processor.Process(err)
141+
assert.False(t, errs.IsRetryable(out))
142+
assert.True(t, errs.IsDependencyError(out))
143+
})
144+
145+
t.Run("transport failure is attributed to the dependency not mysql", func(t *testing.T) {
146+
// mysqlerrs calls any net.Error retryable infra, and *url.Error is one,
147+
// so it would claim this node and drop the dependency attribution if it
148+
// were listed first.
149+
err := fmt.Errorf("send: %w", &url.Error{Op: "Get", URL: "http://api.example", Err: errors.New("connection reset by peer")})
150+
out := processor.Process(err)
151+
assert.True(t, errs.IsRetryable(out))
152+
assert.True(t, errs.IsDependencyError(out), "should be attributed to the HTTP dependency")
153+
})
154+
155+
t.Run("our cancellation is retryable but not a dependency failure", func(t *testing.T) {
156+
err := fmt.Errorf("send: %w", &url.Error{Op: "Get", URL: "http://api.example", Err: context.Canceled})
157+
out := processor.Process(err)
158+
assert.True(t, errs.IsRetryable(out))
159+
assert.False(t, errs.IsDependencyError(out))
160+
})
161+
162+
t.Run("expired deadline is retryable without mysqlerrs claiming it", func(t *testing.T) {
163+
err := fmt.Errorf("send: %w", &url.Error{Op: "Get", URL: "http://api.example", Err: context.DeadlineExceeded})
164+
out := processor.Process(err)
165+
assert.True(t, errs.IsRetryable(out))
166+
assert.True(t, errs.IsDependencyError(out), "should be attributed to the HTTP dependency")
167+
})
168+
169+
t.Run("a controller verdict wins over the classifier", func(t *testing.T) {
170+
// Pass 1 of the processor short-circuits on the existing framework wrap,
171+
// so a 502 a controller decided was fatal stays fatal.
172+
err := errs.NewDependencyError(phttp.NewStatusError(nethttp.StatusBadGateway, nil))
173+
out := processor.Process(err)
174+
assert.Same(t, err, out)
175+
assert.False(t, errs.IsRetryable(out))
176+
})
177+
}
178+
179+
// TestClassifier_WithoutMySQLClassifier covers a service with no MySQL
180+
// dependency: no verdict here may rely on mysqlerrs' net.Error rule.
181+
func TestClassifier_WithoutMySQLClassifier(t *testing.T) {
182+
processor := errs.NewClassifierProcessor(genericerrs.Classifier, Classifier)
183+
184+
tests := []struct {
185+
name string
186+
cause error
187+
wantDependency bool
188+
}{
189+
{name: "connection reset", cause: errors.New("connection reset by peer"), wantDependency: true},
190+
{name: "expired deadline", cause: context.DeadlineExceeded, wantDependency: true},
191+
{name: "our cancellation", cause: context.Canceled, wantDependency: false},
192+
}
193+
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
err := fmt.Errorf("send: %w", &url.Error{Op: "Get", URL: "http://api.example", Err: tt.cause})
197+
out := processor.Process(err)
198+
assert.True(t, errs.IsRetryable(out), "must not depend on mysqlerrs being wired")
199+
assert.Equal(t, tt.wantDependency, errs.IsDependencyError(out))
200+
})
201+
}
202+
}

0 commit comments

Comments
 (0)