kunchenguid--no-mistakes
26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
373 行
10 KiB
Go
373 行
10 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestClassifyTransient_Positive(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
errMsg string
|
|
wantSub string // substring of label
|
|
}{
|
|
{
|
|
name: "claude overloaded_error stderr",
|
|
errMsg: `claude exited: exit status 1: API Error: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`,
|
|
wantSub: "overloaded",
|
|
},
|
|
{
|
|
name: "rate_limit_error",
|
|
errMsg: `claude exited: exit status 1: rate_limit_error: too many requests`,
|
|
wantSub: "rate_limit",
|
|
},
|
|
{
|
|
name: "http 429 in body",
|
|
errMsg: `POST https://api.anthropic.com/v1/messages failed with 429: rate limited`,
|
|
wantSub: "429",
|
|
},
|
|
{
|
|
name: "http 503",
|
|
errMsg: `GET /v3/stream_chat failed with 503: service unavailable`,
|
|
wantSub: "503",
|
|
},
|
|
{
|
|
name: "http 529 anthropic overloaded",
|
|
errMsg: `claude exited: exit status 1: HTTP 529 from api.anthropic.com`,
|
|
wantSub: "529",
|
|
},
|
|
{
|
|
name: "connection refused",
|
|
errMsg: `dial tcp 127.0.0.1:5555: connection refused`,
|
|
wantSub: "connection refused",
|
|
},
|
|
{
|
|
name: "connection reset",
|
|
errMsg: `read tcp 1.2.3.4:80: connection reset by peer`,
|
|
wantSub: "connection reset",
|
|
},
|
|
{
|
|
name: "io timeout",
|
|
errMsg: `Post "https://api.anthropic.com/v1/messages": context deadline exceeded (i/o timeout)`,
|
|
wantSub: "i/o timeout",
|
|
},
|
|
{
|
|
name: "no such host",
|
|
errMsg: `dial tcp: lookup api.anthropic.com: no such host`,
|
|
wantSub: "dns",
|
|
},
|
|
{
|
|
name: "tls handshake",
|
|
errMsg: `Post "https://api.anthropic.com": net/http: TLS handshake timeout`,
|
|
wantSub: "tls",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
label, ok := classifyTransient(errors.New(tc.errMsg))
|
|
if !ok {
|
|
t.Fatalf("expected transient classification for %q", tc.errMsg)
|
|
}
|
|
if !strings.Contains(strings.ToLower(label), strings.ToLower(tc.wantSub)) {
|
|
t.Errorf("label %q does not contain %q", label, tc.wantSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyTransient_Negative(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "auth failure",
|
|
errMsg: `claude exited: exit status 1: API Error: {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`,
|
|
},
|
|
{
|
|
name: "invalid request",
|
|
errMsg: `claude exited: exit status 1: API Error: {"type":"error","error":{"type":"invalid_request_error","message":"Bad Request"}}`,
|
|
},
|
|
{
|
|
name: "missing structured output",
|
|
errMsg: `claude returned no structured output`,
|
|
},
|
|
{
|
|
name: "permission denied",
|
|
errMsg: `permission denied`,
|
|
},
|
|
{
|
|
name: "schema validation",
|
|
errMsg: `JSON output missing required field "summary"`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if label, ok := classifyTransient(errors.New(tc.errMsg)); ok {
|
|
t.Errorf("expected non-transient for %q, got %q", tc.errMsg, label)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyTransient_NilAndContext(t *testing.T) {
|
|
if _, ok := classifyTransient(nil); ok {
|
|
t.Error("nil error should not be transient")
|
|
}
|
|
if _, ok := classifyTransient(context.Canceled); ok {
|
|
t.Error("context.Canceled should not be transient")
|
|
}
|
|
if _, ok := classifyTransient(context.DeadlineExceeded); ok {
|
|
t.Error("context.DeadlineExceeded should not be transient")
|
|
}
|
|
}
|
|
|
|
// withFastBackoff swaps transientBackoff with a near-instant version that
|
|
// preserves ctx-cancel semantics. Returns a restore func.
|
|
func withFastBackoff(t *testing.T) func() {
|
|
t.Helper()
|
|
prev := transientBackoff
|
|
transientBackoff = func(ctx context.Context, attempt int) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(time.Millisecond):
|
|
return nil
|
|
}
|
|
}
|
|
return func() { transientBackoff = prev }
|
|
}
|
|
|
|
func TestRunWithRetry_RetriesTransientThenSucceeds(t *testing.T) {
|
|
defer withFastBackoff(t)()
|
|
|
|
calls := 0
|
|
transientErr := fmt.Errorf("API Error: overloaded_error: please retry")
|
|
var chunks []string
|
|
var attempts []Attempt
|
|
opts := RunOpts{
|
|
OnChunk: func(s string) { chunks = append(chunks, s) },
|
|
OnAttempt: func(attempt Attempt) { attempts = append(attempts, attempt) },
|
|
}
|
|
|
|
res, err := runWithRetry(context.Background(), "claude", opts, 3, classifyTransient, nil, func() (*Result, error) {
|
|
calls++
|
|
if calls < 3 {
|
|
return nil, transientErr
|
|
}
|
|
return &Result{Text: "ok"}, nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected success after retries, got %v", err)
|
|
}
|
|
if res == nil || res.Text != "ok" {
|
|
t.Fatalf("expected result text 'ok', got %+v", res)
|
|
}
|
|
if calls != 3 {
|
|
t.Errorf("expected 3 calls (2 retries + success), got %d", calls)
|
|
}
|
|
if len(attempts) != 3 {
|
|
t.Fatalf("attempts = %d, want 3", len(attempts))
|
|
}
|
|
for i, attempt := range attempts {
|
|
if attempt.Agent != "claude" || attempt.StartedAt.IsZero() || attempt.CompletedAt.IsZero() {
|
|
t.Fatalf("attempt %d = %+v", i, attempt)
|
|
}
|
|
if i < 2 && attempt.Err == nil {
|
|
t.Fatalf("attempt %d must preserve the transient error", i)
|
|
}
|
|
}
|
|
if attempts[2].Result == nil || attempts[2].Result.Text != "ok" {
|
|
t.Fatalf("successful attempt = %+v", attempts[2])
|
|
}
|
|
if len(chunks) < 2 {
|
|
t.Errorf("expected at least 2 retry notification chunks, got %d: %v", len(chunks), chunks)
|
|
}
|
|
for i, c := range chunks {
|
|
if !strings.Contains(strings.ToLower(c), "transient") || !strings.Contains(strings.ToLower(c), "overloaded") {
|
|
t.Errorf("chunk[%d]=%q should mention 'transient' and the classification label", i, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunWithRetry_EmitsRetryLifecycleWhenConfigured(t *testing.T) {
|
|
defer withFastBackoff(t)()
|
|
|
|
calls := 0
|
|
var chunks []string
|
|
var events []LifecycleEvent
|
|
opts := RunOpts{
|
|
OnChunk: func(s string) { chunks = append(chunks, s) },
|
|
OnLifecycle: func(e LifecycleEvent) { events = append(events, e) },
|
|
}
|
|
|
|
_, err := runWithRetry(context.Background(), "codex", opts, 1, classifyTransient, nil, func() (*Result, error) {
|
|
calls++
|
|
if calls == 1 {
|
|
return nil, errors.New("API Error: 503 overloaded")
|
|
}
|
|
return &Result{Text: "ok"}, nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected success after retry, got %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("events = %v, want one retry lifecycle event", events)
|
|
}
|
|
if events[0].Agent != "codex" || events[0].Phase != LifecyclePhaseRetry {
|
|
t.Fatalf("event = %+v, want codex retry", events[0])
|
|
}
|
|
if !strings.Contains(events[0].Message, "attempt 2/2") || !strings.Contains(events[0].Message, "503") {
|
|
t.Fatalf("retry message = %q, want attempt and label", events[0].Message)
|
|
}
|
|
if len(chunks) != 0 {
|
|
t.Fatalf("OnChunk should not duplicate retry lifecycle events, got %v", chunks)
|
|
}
|
|
}
|
|
|
|
func TestRunWithRetry_CallsRetryRecoveryBeforeRetry(t *testing.T) {
|
|
defer withFastBackoff(t)()
|
|
|
|
calls := 0
|
|
recovered := false
|
|
_, err := runWithRetry(context.Background(), "opencode", RunOpts{}, 1, classifyTransient, func(label string) {
|
|
if label == "connection refused" {
|
|
recovered = true
|
|
}
|
|
}, func() (*Result, error) {
|
|
calls++
|
|
if !recovered {
|
|
return nil, errors.New("dial tcp 127.0.0.1:5555: connection refused")
|
|
}
|
|
return &Result{Text: "ok"}, nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected success after retry recovery, got %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("expected one retry after recovery, got %d calls", calls)
|
|
}
|
|
if !recovered {
|
|
t.Fatal("expected retry recovery to run")
|
|
}
|
|
}
|
|
|
|
func TestRunWithRetry_PermanentErrorFailsImmediately(t *testing.T) {
|
|
defer withFastBackoff(t)()
|
|
|
|
calls := 0
|
|
permErr := errors.New("API Error: authentication_error: invalid x-api-key")
|
|
|
|
_, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, classifyTransient, nil, func() (*Result, error) {
|
|
calls++
|
|
return nil, permErr
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected permanent error to propagate")
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("expected single call for permanent error, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRunWithRetry_ExhaustsRetries(t *testing.T) {
|
|
defer withFastBackoff(t)()
|
|
|
|
calls := 0
|
|
transientErr := errors.New("503 service unavailable")
|
|
|
|
_, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, classifyTransient, nil, func() (*Result, error) {
|
|
calls++
|
|
return nil, transientErr
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error after exhausting retries")
|
|
}
|
|
if calls != 4 { // 1 initial + 3 retries
|
|
t.Errorf("expected 4 calls (1 + 3 retries), got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRunWithRetry_RespectsContextCancellation(t *testing.T) {
|
|
// Use a real backoff that would normally take ~1s, but cancel ctx
|
|
// before the first sleep finishes to confirm short-circuit.
|
|
prev := transientBackoff
|
|
transientBackoff = func(ctx context.Context, attempt int) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(5 * time.Second):
|
|
return nil
|
|
}
|
|
}
|
|
defer func() { transientBackoff = prev }()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
transientErr := errors.New("overloaded_error")
|
|
calls := 0
|
|
|
|
go func() {
|
|
time.Sleep(10 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
start := time.Now()
|
|
_, err := runWithRetry(ctx, "claude", RunOpts{}, 3, classifyTransient, nil, func() (*Result, error) {
|
|
calls++
|
|
return nil, transientErr
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error from cancelled context")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
|
t.Errorf("expected fast cancel, took %v", elapsed)
|
|
}
|
|
if calls > 2 {
|
|
t.Errorf("expected at most 2 calls before cancel, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRunWithRetry_CombinedClassifierForClaude(t *testing.T) {
|
|
defer withFastBackoff(t)()
|
|
|
|
// claudeRetryClassifier should retry both transient API errors AND errNoStructuredOutput.
|
|
calls := 0
|
|
_, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, claudeRetryClassifier, nil, func() (*Result, error) {
|
|
calls++
|
|
return nil, errNoStructuredOutput
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error after exhausting retries")
|
|
}
|
|
if calls != 4 {
|
|
t.Errorf("expected 4 calls for errNoStructuredOutput retries, got %d", calls)
|
|
}
|
|
if !errors.Is(err, errNoStructuredOutput) {
|
|
t.Errorf("expected errNoStructuredOutput to surface as final error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTransientBackoffDuration_Progression(t *testing.T) {
|
|
// Without jitter, progression should be base * 4^(attempt-1).
|
|
base := time.Second
|
|
for _, tc := range []struct {
|
|
attempt int
|
|
want time.Duration
|
|
}{
|
|
{1, base},
|
|
{2, 4 * base},
|
|
{3, 16 * base},
|
|
} {
|
|
got := transientBackoffBaseDuration(tc.attempt, base)
|
|
if got != tc.want {
|
|
t.Errorf("attempt %d: want %v, got %v", tc.attempt, tc.want, got)
|
|
}
|
|
}
|
|
}
|