| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | func TestManagedRecoveryOneHTTPRequestAndServerDelay(t *testing.T) { |
| 16 | calls := 0 |
| 17 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 18 | calls++ |
| 19 | w.Header().Set("Retry-After", "120") |
| 20 | w.WriteHeader(http.StatusTooManyRequests) |
| 21 | _, _ = w.Write([]byte(`{"error":{"code":"rate_limit"}}`)) |
| 22 | })) |
| 23 | defer server.Close() |
| 24 | ctx := WithManagedRecovery(context.Background()) |
| 25 | _, err := SendWithRetry(ctx, server.Client(), SendOptions{}, func(ctx context.Context) (*http.Request, error) { |
| 26 | return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) |
| 27 | }) |
| 28 | f := ClassifyRecovery(err) |
| 29 | if calls != 1 || !f.Retryable || f.RetryAfter != 2*time.Minute || f.Code != "rate_limit" { |
| 30 | t.Fatalf("calls=%d failure=%+v err=%v", calls, f, err) |
| 31 | } |
| 32 | } |
| 33 | func TestRecoveryDoesNotRetryPermanentOrUnknownErrors(t *testing.T) { |
| 34 | for _, err := range []error{context.Canceled, context.DeadlineExceeded, errors.New("arbitrary failure"), &APIError{Status: 429, Body: `{"error":{"code":"insufficient_quota"}}`}, &APIError{Status: 503, ShouldRetry: "false"}, &AuthError{Status: 401}} { |
| 35 | if ClassifyRecovery(err).Retryable { |
| 36 | t.Fatalf("retried %v", err) |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestRecoveryWaitExhaustedIsTerminalAndDiagnosable(t *testing.T) { |
| 42 | cause := &APIError{Provider: "p", Status: 503, Body: `{"error":{"code":"overloaded"}}`, TraceID: "trace_1"} |
| 43 | err := fmt.Errorf("run: %w", &RecoveryWaitExhaustedError{Phase: "headers", Code: "overloaded", Status: 503, Waited: 10 * time.Minute, Attempts: 13, Cause: cause}) |
| 44 | if f := ClassifyRecovery(err); f.Retryable || f.Phase != "headers" || f.Status != 503 || f.Code != "overloaded" { |
| 45 | t.Fatalf("failure=%+v", f) |
| 46 | } |
| 47 | if d := DiagnoseFailure(err); d.Kind != "recovery_wait_exhausted" || d.Status != 503 || d.TraceID != "trace_1" { |
| 48 | t.Fatalf("diagnostic=%+v", d) |
| 49 | } |
| 50 | if !errors.Is(err, cause) || !strings.Contains(err.Error(), "provider unreachable for 10m0s (headers): p: status 503") { |
| 51 | t.Fatalf("err=%v", err) |
| 52 | } |
| 53 | if AsRecoveryWaitExhausted(cause) != nil || AsRecoveryWaitExhausted(nil) != nil { |
| 54 | t.Fatal("plain failures must not read as an exhausted wait") |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func TestOpaqueGoBadRequestDoesNotGuessReplayFailure(t *testing.T) { |
| 59 | // Observed from Go's custom DeepSeek Anthropic route after an invalid |
| 60 | // replay. The same opaque body cannot establish the cause for real users. |
| 61 | err := &APIError{Provider: "opencode-go", Status: 400, Body: `{"model":"deepseek-v4-flash"}`} |
| 62 | if AsReasoningReplayError(err) != nil || ClassifyRecovery(err).Retryable { |
| 63 | t.Fatal("opaque 400 must not trigger guessed history repair or automatic regeneration") |
| 64 | } |
| 65 | } |
| 66 | func TestWriteEvidenceNeverEntersModelMessages(t *testing.T) { |
| 67 | original := []Message{{Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "x", Name: "write_file", Arguments: `{}`, WriteIntents: []json.RawMessage{json.RawMessage(`{"version":99,"future":"retain"}`)}}}}} |
| 68 | projected := ModelMessages(original) |
| 69 | if len(projected[0].ToolCalls[0].WriteIntents) != 0 || len(original[0].ToolCalls[0].WriteIntents) != 1 { |
| 70 | t.Fatal("projection leaked or mutated evidence") |
| 71 | } |
| 72 | b, err := json.Marshal(original) |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | var decoded []Message |
| 77 | if err := json.Unmarshal(b, &decoded); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | if string(decoded[0].ToolCalls[0].WriteIntents[0]) != string(original[0].ToolCalls[0].WriteIntents[0]) { |
| 81 | t.Fatal("unknown version lost") |
| 82 | } |
| 83 | } |
| 84 |