From f0d5c0b76de8887f4d09bb184f900af64f51dce6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 22 Aug 2026 02:21:43 -0700 Subject: [PATCH 1/2] Adopt the SDK's response-body cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hey-sdk v0.13.0 bounds its own reads: the transport NewClient builds caps JSON and HTML response bodies at 16 MiB, success and error alike, so the interim RoundTripper in internal/cmd/sdk_transport.go comes out. What the SDK does that the interim cap did not is keep an oversized error response's status: the refusal arrives as the *hey.Error for the status wrapping hey.ErrResponseTooLarge. The thread loader therefore classifies a failed message read by status before size — an oversized 500, 429 or 401 is still the service failing and stops the fan-out, an oversized 404 is still a missing message, and only an oversized success marks the entry over_limit. Closes #248 --- AGENTS.md | 14 ++- go.mod | 2 +- go.sum | 4 +- internal/cmd/sdk.go | 1 - internal/cmd/sdk_transport.go | 142 ---------------------- internal/cmd/sdk_transport_test.go | 181 ---------------------------- internal/cmd/thread_partial_test.go | 4 +- internal/threadload/sdk.go | 27 ++++- internal/threadload/sdk_test.go | 126 +++++++++++++++++++ 9 files changed, 162 insertions(+), 339 deletions(-) delete mode 100644 internal/cmd/sdk_transport.go delete mode 100644 internal/cmd/sdk_transport_test.go create mode 100644 internal/threadload/sdk_test.go diff --git a/AGENTS.md b/AGENTS.md index 84ea872a..c6d5c7c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,11 +121,15 @@ because both were mis-stated here before: `Topics().GetEntries` throws that header away, which is what `GetEntriesPage` exists for. - The SDK's generated parsers read a response with `io.ReadAll`, so until it bounds its - own reads (#248) `internal/cmd/sdk_transport.go` hands it a transport that caps JSON and - text bodies at 16 MiB, success and error alike, decompressed, and leaves blobs — which - the SDK streams or caps itself — alone. It sits inside the SDK's own http.Client, so the - SDK's timeout, redirect credential stripping, logging and hooks still apply. + The SDK bounds its own reads: its transport caps JSON and HTML response bodies — + success and error alike, decompressed — at `hey.DefaultMaxResponseBodyBytes` (16 MiB; + `hey.WithMaxResponseBodyBytes` to change it, and there is no opt-out — a zero or + negative value means the default), leaving blobs to the SDK's own streaming and caps. + An oversized *error* response keeps its status: the refusal arrives as the `*hey.Error` + for the status wrapping `hey.ErrResponseTooLarge`, which is why + `internal/threadload/sdk.go` classifies a failed message read by status before size — + an oversized 500 is still systemic and an oversized 404 is still just a missing + message; only an oversized success is `over_limit`. - **A reply's recipients come from the entry it answers.** `Messages().Get` carries that entry's `Addressed` (`directly`/`copied`/`blindcopied`), and `recipientsForReplyTo` — in `internal/cmd/thread_reply.go` for `hey reply`, and in `internal/tui/compose.go` for diff --git a/go.mod b/go.mod index 38a32611..28da28c0 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537 - github.com/basecamp/hey-sdk/go v0.12.0 + github.com/basecamp/hey-sdk/go v0.13.0 github.com/charmbracelet/x/ansi v0.11.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 diff --git a/go.sum b/go.sum index 81d31366..f3cf0068 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537 h1:OE1VMvKkpI+Vo7aP5IDRG6PNXW2IVMlLUWLgBcybGNc= github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537/go.mod h1:9+DEydJMniIKraEsd4fDJpFEnqlLUJ6XhAswxRBaITk= -github.com/basecamp/hey-sdk/go v0.12.0 h1:ZOe3hQmuCeJcUGC0qptyRHXiy0fJ4Cyh5tk/buO8SY4= -github.com/basecamp/hey-sdk/go v0.12.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +github.com/basecamp/hey-sdk/go v0.13.0 h1:aQbU/TJp1AeYTLZhAjXQv1VVHrzmrYAE7lVS9TD5D8o= +github.com/basecamp/hey-sdk/go v0.13.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= diff --git a/internal/cmd/sdk.go b/internal/cmd/sdk.go index d8f56799..94903a6e 100644 --- a/internal/cmd/sdk.go +++ b/internal/cmd/sdk.go @@ -78,7 +78,6 @@ func initSDK(authMgr *auth.Manager, baseURL string) { var opts []hey.ClientOption opts = append(opts, hey.WithAuthStrategy(&cliAuthStrategy{mgr: authMgr})) opts = append(opts, hey.WithUserAgent(version.UserAgent()+" "+hey.DefaultUserAgent)) - opts = append(opts, hey.WithTransport(newCappedTransport(maxTextResponseBytes))) if verboseFlag > 0 { opts = append(opts, hey.WithLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))) diff --git a/internal/cmd/sdk_transport.go b/internal/cmd/sdk_transport.go deleted file mode 100644 index 7114087e..00000000 --- a/internal/cmd/sdk_transport.go +++ /dev/null @@ -1,142 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "mime" - "net/http" - "strings" - "time" - - "github.com/basecamp/hey-cli/internal/threadload" -) - -// The SDK's generated parsers read every response body with io.ReadAll before -// anything here can apply a budget, so a server answering one entries page or one -// message with gigabytes was a memory exhaustion. Until the SDK bounds its own reads -// (basecamp/hey-cli#248), the bound lives in the transport the SDK is handed: a -// RoundTripper that caps what a text-bearing response can deliver, success and error -// alike, before a parser sees it. -// -// It sits inside the SDK's own http.Client rather than replacing it, so the SDK's -// timeout, its redirect credential stripping, its logging and its hooks all still -// apply. It reads decompressed bytes: the transport it wraps is the one that -// negotiated the encoding, and what comes out of it is what the parser would keep. -// -// A success body past the limit is refused — its first read past the cap fails — since -// a parser that buffers it would buffer it whole. An error body past the limit is cut -// off at the cap instead: the status is what matters about an error, and a refusal -// would hide it behind a read failure. Which responses are capped is decided by the -// request, not by what the server says it answered with: the SDK asks for application/json where a generated parser will -// buffer the answer and for text/html where GetHTML will, and asks for */* for a blob, -// which it streams to a destination of any size (DownloadBlob) or buffers under its -// own MaxResponseBodyBytes (GetBlob). A server that labels a JSON answer as a PNG is -// still capped; an attachment that happens to be a text file is still streamed. - -// maxTextResponseBytes is the most a JSON or text response may deliver: 16 MiB, which -// is a message with a very large HTML body several times over. -const maxTextResponseBytes int64 = 16 << 20 - -// ErrResponseTooLarge is the error a capped body ends with once it passes the cap. It -// wraps threadload.ErrOverLimit, so a loader that meets it through the SDK knows the -// one message was too large rather than the service failing. -var ErrResponseTooLarge = fmt.Errorf("%w: response body exceeded the size limit", threadload.ErrOverLimit) - -// cappedTransport wraps an http.RoundTripper so that text-bearing responses cannot -// deliver more than limit decompressed bytes. -type cappedTransport struct { - inner http.RoundTripper - limit int64 -} - -func newCappedTransport(limit int64) *cappedTransport { - inner := http.DefaultTransport - if base, ok := http.DefaultTransport.(*http.Transport); ok { - // The same pooling the SDK's own default transport sets. - transport := base.Clone() - transport.MaxIdleConns = 100 - transport.MaxIdleConnsPerHost = 10 - transport.IdleConnTimeout = 90 * time.Second - inner = transport - } - return &cappedTransport{inner: inner, limit: limit} -} - -func (t *cappedTransport) RoundTrip(req *http.Request) (*http.Response, error) { - resp, err := t.inner.RoundTrip(req) - if err != nil || resp == nil || resp.Body == nil || !isParsedRequest(req) { - return resp, err - } - // A body declared past the limit is refused on its first read rather than at the - // round trip: a round-trip error is one the SDK retries, and the body would be too - // large again; a read error is the same failure the streamed case produces. - if resp.StatusCode >= 400 { - resp.Body = &truncatedBody{Reader: io.LimitReader(resp.Body, t.limit), closer: resp.Body} - return resp, nil - } - remaining := t.limit - if resp.ContentLength > t.limit { - remaining = -1 - } - resp.Body = &cappedBody{ReadCloser: resp.Body, remaining: remaining, request: req} - return resp, nil -} - -// isParsedRequest reports a request whose answer the SDK buffers and parses, by what -// the request asked for. Anything it did not ask for as JSON or HTML — a blob's */*, an -// export's text/csv — it handles by streaming or under its own bound. -func isParsedRequest(req *http.Request) bool { - accept := req.Header.Get("Accept") - if accept == "" { - return true - } - for _, part := range strings.Split(accept, ",") { - mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(part)) - if err != nil { - continue - } - if mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") || mediaType == "text/html" { - return true - } - } - return false -} - -// truncatedBody is an error body cut off at the cap: what the server said, up to the -// limit, with the status still standing. -type truncatedBody struct { - io.Reader - closer io.Closer -} - -func (b *truncatedBody) Close() error { return b.closer.Close() } - -type cappedBody struct { - io.ReadCloser - remaining int64 - request *http.Request -} - -// Read delivers up to the limit and fails on the first byte past it. A body that is -// exactly the limit is read whole: with nothing remaining, the next read still asks the -// wrapped body for one byte, and gets its EOF rather than a refusal. A body declared -// past the limit starts with a negative remainder and fails on its first read. -func (b *cappedBody) Read(p []byte) (int, error) { - if len(p) == 0 { - return 0, nil - } - if b.remaining < 0 { - return 0, fmt.Errorf("%s %s: declared past the limit: %w", b.request.Method, b.request.URL.Path, ErrResponseTooLarge) - } - if int64(len(p)) > b.remaining+1 { - p = p[:b.remaining+1] - } - n, err := b.ReadCloser.Read(p) - if int64(n) > b.remaining { - n = int(b.remaining) - b.remaining = 0 - return n, fmt.Errorf("%s %s: %w", b.request.Method, b.request.URL.Path, ErrResponseTooLarge) - } - b.remaining -= int64(n) - return n, err -} diff --git a/internal/cmd/sdk_transport_test.go b/internal/cmd/sdk_transport_test.go deleted file mode 100644 index 8d3940f0..00000000 --- a/internal/cmd/sdk_transport_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package cmd - -import ( - "compress/gzip" - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// cappedClient caps text bodies at a kilobyte, which the tests overrun by a few. -func cappedClient(t *testing.T) *http.Client { - t.Helper() - return &http.Client{Transport: &cappedTransport{inner: http.DefaultTransport, limit: 1024}} -} - -// get asks the way the SDK's generated client does, for JSON; getAccepting asks for -// whatever the caller names, the way a blob or an export does. -func get(t *testing.T, client *http.Client, url string) ([]byte, error) { - t.Helper() - return getAccepting(t, client, url, "application/json") -} - -func getAccepting(t *testing.T, client *http.Client, url, accept string) ([]byte, error) { - t.Helper() - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Accept", accept) - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - return io.ReadAll(resp.Body) -} - -func TestCappedTransportPassesABodyWithinTheLimit(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":1,"content":"short"}`) - })) - t.Cleanup(server.Close) - - body, err := get(t, cappedClient(t), server.URL) - if err != nil || string(body) != `{"id":1,"content":"short"}` { - t.Fatalf("body = %q, err = %v", body, err) - } -} - -// A body past the limit ends in ErrResponseTooLarge before the parser has it all, -// whether the server declared its length or streamed it. -func TestCappedTransportStopsAnOversizedBody(t *testing.T) { - for name, declare := range map[string]bool{"declared": true, "streamed": false} { - t.Run(name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - if declare { - w.Header().Set("Content-Length", "4096") - } - flusher, _ := w.(http.Flusher) - for range 64 { - _, _ = io.WriteString(w, strings.Repeat("x", 64)) - if flusher != nil && !declare { - flusher.Flush() - } - } - })) - t.Cleanup(server.Close) - - body, err := get(t, cappedClient(t), server.URL) - if !errors.Is(err, ErrResponseTooLarge) { - t.Fatalf("err = %v (body %d bytes), want ErrResponseTooLarge", err, len(body)) - } - if len(body) > 1024 { - t.Errorf("delivered %d bytes past a 1024-byte limit", len(body)) - } - }) - } -} - -// The limit counts decompressed bytes: a small gzip that inflates past it is refused. -func TestCappedTransportCountsDecompressedBytes(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Encoding", "gzip") - zw := gzip.NewWriter(w) - _, _ = io.WriteString(zw, `{"content":"`+strings.Repeat("a", 8192)+`"}`) - _ = zw.Close() - })) - t.Cleanup(server.Close) - - _, err := get(t, cappedClient(t), server.URL) - if !errors.Is(err, ErrResponseTooLarge) { - t.Fatalf("err = %v, want ErrResponseTooLarge for an inflated body", err) - } -} - -// An error body past the limit is cut off at the limit, not refused: the status is -// what matters about an error, and a read failure would hide it. -func TestCappedTransportTruncatesErrorBodies(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - _, _ = io.WriteString(w, `{"error":"`+strings.Repeat("e", 4096)+`"}`) - })) - t.Cleanup(server.Close) - - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) - req.Header.Set("Accept", "application/json") - resp, err := cappedClient(t).Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil || resp.StatusCode != http.StatusInternalServerError || len(body) != 1024 { - t.Fatalf("status %d, %d bytes, err %v; want the status with the body cut at the limit", resp.StatusCode, len(body), err) - } -} - -// A body of exactly the limit is read whole; one byte more is refused. -func TestCappedTransportAcceptsABodyExactlyAtTheLimit(t *testing.T) { - for _, size := range []int{1024, 1025} { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - flusher, _ := w.(http.Flusher) - _, _ = io.WriteString(w, strings.Repeat("x", size)) - if flusher != nil { - flusher.Flush() - } - })) - body, err := get(t, cappedClient(t), server.URL) - server.Close() - switch size { - case 1024: - if err != nil || len(body) != 1024 { - t.Errorf("exactly at the limit: %d bytes, err = %v, want the whole body", len(body), err) - } - default: - if !errors.Is(err, ErrResponseTooLarge) { - t.Errorf("one past the limit: err = %v, want ErrResponseTooLarge", err) - } - } - } -} - -// What is capped is decided by the request. A blob the SDK asked for with */* streams -// whole whatever it turns out to be — a text file included — and a JSON answer the -// server labels as an image is capped all the same. -func TestCappedTransportDecidesByTheRequest(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", r.URL.Query().Get("type")) - _, _ = io.WriteString(w, strings.Repeat("%", 4096)) - })) - t.Cleanup(server.Close) - - for _, test := range []struct { - accept, contentType string - capped bool - }{ - {"*/*", "application/pdf", false}, - {"*/*", "text/plain", false}, - {"*/*", "application/json", false}, - {"text/csv", "text/csv", false}, - {"application/json", "application/json", true}, - {"application/json", "image/png", true}, - {"application/json", "application/octet-stream", true}, - {"text/html", "text/html", true}, - {"", "application/json", true}, - } { - body, err := getAccepting(t, cappedClient(t), server.URL+"?type="+test.contentType, test.accept) - if capped := errors.Is(err, ErrResponseTooLarge); capped != test.capped { - t.Errorf("Accept %q, Content-Type %q: capped = %v (err %v, %d bytes), want %v", test.accept, test.contentType, capped, err, len(body), test.capped) - } - } -} diff --git a/internal/cmd/thread_partial_test.go b/internal/cmd/thread_partial_test.go index 0ba033f3..4767b9f2 100644 --- a/internal/cmd/thread_partial_test.go +++ b/internal/cmd/thread_partial_test.go @@ -12,6 +12,8 @@ import ( "sync" "testing" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/threadload" ) @@ -386,7 +388,7 @@ func TestThreadsMarkAnOversizedMessageOverLimit(t *testing.T) { reads.mu.Lock() reads.messages++ reads.mu.Unlock() - w.Header().Set("Content-Length", strconv.FormatInt(maxTextResponseBytes+1, 10)) + w.Header().Set("Content-Length", strconv.FormatInt(hey.DefaultMaxResponseBodyBytes+1, 10)) w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, `{"id":12,"content":"`) default: diff --git a/internal/threadload/sdk.go b/internal/threadload/sdk.go index 13ef268e..0aa30661 100644 --- a/internal/threadload/sdk.go +++ b/internal/threadload/sdk.go @@ -42,22 +42,37 @@ func (s sdkSource) EntriesPage(ctx context.Context, topicID int64, cursor string // Message reads one entry's message. A rate limit, an expired credential, a server // error or a lost connection is about the service, not the message, and is marked // systemic so the loader stops the fan-out rather than asking two thousand more times. -// A response the transport refused as too large is ErrOverLimit for that entry alone. +// A response the SDK's transport refused as too large is ErrOverLimit for that entry +// alone — but only when the refusal carries no HTTP status. An oversized *error* body +// arrives as the *hey.Error for its status wrapping hey.ErrResponseTooLarge, and the +// status is what it means: an oversized 500 is still the service failing, and an +// oversized 404 is still a message that is not there. func (s sdkSource) Message(ctx context.Context, entryID int64) (*generated.Message, error) { message, err := s.client.Messages().Get(ctx, entryID) if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } - // The transport wraps ErrOverLimit; the SDK may or may not keep the chain. - if errors.Is(err, ErrOverLimit) || strings.Contains(err.Error(), ErrOverLimit.Error()) { - return nil, fmt.Errorf("%w: %w", ErrOverLimit, err) - } - return nil, systemic(apierr.FromSDK(err)) + return nil, classifyMessageError(err) } return message, nil } +// classifyMessageError sorts a failed message read by status before size: an error that +// carries an HTTP status means the server answered, and the status is what it means +// whether or not the body also blew the cap. Only a refusal with no status — an oversized +// success — is about the one message being too large. +func classifyMessageError(err error) error { + var statusErr *hey.Error + if errors.As(err, &statusErr) && statusErr.HTTPStatus != 0 { + return systemic(apierr.FromSDK(err)) + } + if errors.Is(err, hey.ErrResponseTooLarge) { + return fmt.Errorf("%w: %w", ErrOverLimit, err) + } + return systemic(apierr.FromSDK(err)) +} + func systemic(err error) error { var apiErr *apierr.Error if errors.As(err, &apiErr) { diff --git a/internal/threadload/sdk_test.go b/internal/threadload/sdk_test.go new file mode 100644 index 00000000..00103773 --- /dev/null +++ b/internal/threadload/sdk_test.go @@ -0,0 +1,126 @@ +package threadload + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +// testCap keeps the oversized fixtures small: the SDK refuses a declared length past the +// cap on the body's first read, so nothing here allocates a default-sized 16 MiB body. +const testCap int64 = 256 + +// oversizedMessageSource serves /messages/1.json with the given status and a +// Content-Length past the cap, and answers Message through a real SDK client with the cap +// set to testCap. The statuses used here — 200, 401, 404 — are ones the generated client +// does not retry with backoff; a 429 or a 500 through a real client costs seven seconds +// of sleeps, which is what TestClassifyMessageError covers those shapes without. +func oversizedMessageSource(t *testing.T, status int) (Source, func() int) { + t.Helper() + reads := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reads++ + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.FormatInt(testCap+1, 10)) + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"truncated":"`) + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL, CacheEnabled: false}, + &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxResponseBodyBytes(testCap), + hey.WithMaxRetries(0)) + return NewSDKSource(client), func() int { return reads } +} + +func TestSDKSourceMarksAnOversizedMessageOverLimit(t *testing.T) { + source, reads := oversizedMessageSource(t, http.StatusOK) + + _, err := source.Message(context.Background(), 1) + if !errors.Is(err, ErrOverLimit) { + t.Errorf("err = %v, want ErrOverLimit", err) + } + if errors.Is(err, ErrSystemic) { + t.Errorf("err = %v, an oversized body is about one message, not the service", err) + } + if reads() != 1 { + t.Errorf("reads = %d, want 1: an oversized body is not retried", reads()) + } +} + +// An oversized error response keeps its status: the SDK wraps the refusal in the *Error +// for the status, and the status is what classifies it. A 401 is the service refusing +// this client, so it is systemic, whether or not its body also blew the cap. +func TestSDKSourceKeepsAnOversizedAuthErrorSystemic(t *testing.T) { + source, _ := oversizedMessageSource(t, http.StatusUnauthorized) + + _, err := source.Message(context.Background(), 1) + if !errors.Is(err, ErrSystemic) { + t.Errorf("err = %v, want ErrSystemic", err) + } + if errors.Is(err, ErrOverLimit) { + t.Errorf("err = %v, an oversized 401 is the service refusing the client, not one message over the limit", err) + } +} + +func TestSDKSourceKeepsAnOversizedNotFoundAnOrdinaryFailure(t *testing.T) { + source, _ := oversizedMessageSource(t, http.StatusNotFound) + + _, err := source.Message(context.Background(), 1) + if err == nil { + t.Fatal("err = nil, want a failure") + } + if errors.Is(err, ErrOverLimit) { + t.Errorf("err = %v, a 404 is a message that is not there, not one over the limit", err) + } + if errors.Is(err, ErrSystemic) { + t.Errorf("err = %v, a 404 is about one message, not the service", err) + } +} + +// refusal is the read error the SDK's capped body ends with, and statusRefusal is the +// *Error CheckResponse builds for an error status whose body was refused — the refusal +// as its Cause, so errors.As finds the status and errors.Is still finds +// ErrResponseTooLarge (the SDK's body_limit tests pin that shape). +func refusal() error { + return fmt.Errorf("GET /messages/1.json: %w of %d bytes", hey.ErrResponseTooLarge, testCap) +} + +func statusRefusal(code string, status int) error { + return &hey.Error{Code: code, Message: "refused", HTTPStatus: status, Cause: refusal()} +} + +// TestClassifyMessageError covers the statuses the generated client would retry with +// seconds of backoff before answering — a 429 and a 500 — against the error shapes the +// SDK hands back, plus the shapes the real-client tests above already prove end to end. +func TestClassifyMessageError(t *testing.T) { + for _, tt := range []struct { + name string + err error + overLimit bool + systemicIs bool + }{ + {"oversized success", refusal(), true, false}, + {"oversized 500", statusRefusal(hey.CodeAPI, 500), false, true}, + {"oversized 429", statusRefusal(hey.CodeRateLimit, 429), false, true}, + {"oversized 404", statusRefusal(hey.CodeNotFound, 404), false, false}, + } { + t.Run(tt.name, func(t *testing.T) { + err := classifyMessageError(tt.err) + if errors.Is(err, ErrOverLimit) != tt.overLimit { + t.Errorf("errors.Is(err, ErrOverLimit) = %v, want %v (err = %v)", !tt.overLimit, tt.overLimit, err) + } + if errors.Is(err, ErrSystemic) != tt.systemicIs { + t.Errorf("errors.Is(err, ErrSystemic) = %v, want %v (err = %v)", !tt.systemicIs, tt.systemicIs, err) + } + }) + } +} From 43c306a8db273dea201bd30c7fb55f57c2dcacff Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 22 Aug 2026 02:29:02 -0700 Subject: [PATCH 2/2] Update the Nix vendorHash for hey-sdk v0.13.0 --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index f359d283..7f54e781 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-kNjpfSUd5V8HDyHd2VhdzpPHfPEA0vU0DGNXx+iYyaU="; + vendorHash = "sha256-rH/n1+ZcsiwkoKPgHNWepzZR0aFuuHwk3MQm3bXjipc="; subPackages = [ "cmd/hey" ];