From 7863048a241bb12858eebc298020136271facdf4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Oct 2026 17:38:08 +0000 Subject: [PATCH] test(adapters/linear): assert the deadline branch returns RateLimited in the deadline test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestRateLimitDeadlineReturnsTypedRateLimited checked only that a 429 with a backoff past the caller deadline returns *linear.RateLimited. The attempt cap and the MaxElapsed ceiling return the same type, so the test passed whichever branch fired. It now records the "outcome" attribute of the adapter's retry log, through the public Options.Logger field, and requires exactly ["deadline"]. Fixes #111 _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --- adapters/linear/harden_capability_test.go | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/adapters/linear/harden_capability_test.go b/adapters/linear/harden_capability_test.go index ce09d50..3e1d2ed 100644 --- a/adapters/linear/harden_capability_test.go +++ b/adapters/linear/harden_capability_test.go @@ -4,9 +4,12 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "net/http/httptest" + "slices" "strings" + "sync" "testing" "time" @@ -184,9 +187,11 @@ func TestRateLimitDeadlineReturnsTypedRateLimited(t *testing.T) { api := newLinearAPIServer(t, 3600) api.rateLimit = 100 // always throttle now := func() time.Time { return time.Now() } + outcomes := &retryOutcomeRecorder{} bot, adapter := newLinearRuntime(t, api, linear.Options{ WebhookSecret: "whsec", Now: now, + Logger: slog.New(outcomes), // Backoff far larger than the deadline so the pre-sleep deadline check fires // on the first retry rather than the attempt cap or MaxElapsed. RetryPolicy: linear.RetryPolicy{MaxAttempts: 5, MaxElapsed: time.Hour, BaseDelay: time.Hour, MaxDelay: time.Hour}, @@ -215,6 +220,13 @@ func TestRateLimitDeadlineReturnsTypedRateLimited(t *testing.T) { if _, ok := errors.AsType[*linear.RateLimited](err); !ok { t.Fatalf("err = %v, want *linear.RateLimited (deadline-bounded, not slept off)", err) } + // The typed error alone does not show which branch returned it: the + // attempt cap or the MaxElapsed ceiling also return *RateLimited. The + // retry log names the branch, so require exactly one "deadline" outcome + // and no other retry decision. + if got, want := outcomes.all(), []string{"deadline"}; !slices.Equal(got, want) { + t.Fatalf("retry outcomes = %q, want %q (the deadline branch must return the error)", got, want) + } case <-time.After(5 * time.Second): // A correct adapter returns after two roundtrips with no sleep. A broken // one blocks at least until the 10s ctx deadline, or the full one-hour @@ -223,6 +235,44 @@ func TestRateLimitDeadlineReturnsTypedRateLimited(t *testing.T) { } } +// retryOutcomeRecorder is a slog.Handler that keeps the "outcome" attribute of +// each "linear rate limited" record, so a test can tell which retry branch +// (retry, exhausted, ceiling, deadline) the adapter took. +type retryOutcomeRecorder struct { + mu sync.Mutex + outcomes []string +} + +func (*retryOutcomeRecorder) Enabled(context.Context, slog.Level) bool { return true } + +func (r *retryOutcomeRecorder) Handle(_ context.Context, rec slog.Record) error { + if rec.Message != "linear rate limited" { + return nil + } + rec.Attrs(func(a slog.Attr) bool { + if a.Key != "outcome" { + return true + } + r.mu.Lock() + r.outcomes = append(r.outcomes, a.Value.String()) + r.mu.Unlock() + return false + }) + return nil +} + +// WithAttrs and WithGroup return the receiver: the adapter logs retry records +// on its root logger, so no derived logger needs its own state. +func (r *retryOutcomeRecorder) WithAttrs([]slog.Attr) slog.Handler { return r } + +func (r *retryOutcomeRecorder) WithGroup(string) slog.Handler { return r } + +func (r *retryOutcomeRecorder) all() []string { + r.mu.Lock() + defer r.mu.Unlock() + return slices.Clone(r.outcomes) +} + // TestRateLimitRetryStopsAtContextCancel verifies bounded retry never sleeps past // the request context: a cancelled context aborts the retry loop with the context // error rather than a RateLimited (the first-thought window is never violated).