|
4 | 4 | "bytes" |
5 | 5 | "errors" |
6 | 6 | "io" |
| 7 | + "log/slog" |
7 | 8 | "net/http" |
8 | 9 | "net/http/httptest" |
9 | 10 | "strings" |
@@ -364,6 +365,125 @@ func TestUsageLimitRetryTransportClaudeFableFallsBackAfterPoolExhausted(t *testi |
364 | 365 | } |
365 | 366 | } |
366 | 367 |
|
| 368 | +func TestUsageLimitRetryTransportClaudeFableFallbackSuppressesExhaustedLog(t *testing.T) { |
| 369 | + server, store := claudeFailoverServer(t) |
| 370 | + if _, err := store.Put("claude", "session-fable", "cooked@example.com", ""); err != nil { |
| 371 | + t.Fatal(err) |
| 372 | + } |
| 373 | + stub := &stubRoundTripper{responses: func(req *http.Request) *http.Response { |
| 374 | + h := http.Header{} |
| 375 | + h.Set("Anthropic-Ratelimit-Unified-Status", "rejected") |
| 376 | + return &http.Response{ |
| 377 | + StatusCode: http.StatusTooManyRequests, |
| 378 | + Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}`)), |
| 379 | + Header: h, |
| 380 | + } |
| 381 | + }} |
| 382 | + bedrockRT := bedrockRoundTripFunc(func(req *http.Request) (*http.Response, error) { |
| 383 | + return &http.Response{ |
| 384 | + StatusCode: http.StatusOK, |
| 385 | + Body: io.NopCloser(strings.NewReader(`{"type":"message","usage":{"output_tokens":3}}`)), |
| 386 | + Header: http.Header{"Content-Type": []string{"application/json"}}, |
| 387 | + }, nil |
| 388 | + }) |
| 389 | + server.Bedrock = &BedrockConfig{ |
| 390 | + Region: "us-east-1", |
| 391 | + Sources: []BedrockCredentialSource{{Name: "aw0", Credentials: staticBedrockCreds()}}, |
| 392 | + Transport: bedrockRT, |
| 393 | + } |
| 394 | + var logBuf bytes.Buffer |
| 395 | + logger := slog.New(slog.NewTextHandler(&logBuf, nil)) |
| 396 | + fableBody := []byte(`{"model":"claude-fable-5","max_tokens":8,"messages":[]}`) |
| 397 | + req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(fableBody)) |
| 398 | + if err != nil { |
| 399 | + t.Fatal(err) |
| 400 | + } |
| 401 | + req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(fableBody)), nil } |
| 402 | + req.Header.Set("Authorization", "Bearer tok-cooked") |
| 403 | + transport := usageLimitRetryTransport{ |
| 404 | + base: stub, |
| 405 | + server: &server, |
| 406 | + logger: logger, |
| 407 | + provider: accounts.ProviderClaude, |
| 408 | + agent: "claude", |
| 409 | + session: "session-fable", |
| 410 | + account: "cooked@example.com", |
| 411 | + method: http.MethodPost, |
| 412 | + path: "/v1/messages", |
| 413 | + maxAttempts: 4, |
| 414 | + fableFallback: func() (*http.Response, bool) { |
| 415 | + return server.claudeFableFallbackResponse(req, fableBody) |
| 416 | + }, |
| 417 | + } |
| 418 | + response, err := transport.RoundTrip(req) |
| 419 | + if err != nil { |
| 420 | + t.Fatal(err) |
| 421 | + } |
| 422 | + defer response.Body.Close() |
| 423 | + if response.StatusCode != http.StatusOK { |
| 424 | + t.Fatalf("status = %d, want 200 from bedrock after pool exhausted", response.StatusCode) |
| 425 | + } |
| 426 | + logs := logBuf.String() |
| 427 | + if !strings.Contains(logs, "serving claude fable via fallback chain") { |
| 428 | + t.Fatalf("expected fable fallback save log; logs=\n%s", logs) |
| 429 | + } |
| 430 | + if strings.Contains(logs, "returned to client after failover exhausted") { |
| 431 | + t.Fatalf("did not expect client-facing failover-exhaustion signal on fallback save; logs=\n%s", logs) |
| 432 | + } |
| 433 | +} |
| 434 | + |
| 435 | +func TestUsageLimitRetryTransportClaudeFableFallbackFailureLogsExhausted(t *testing.T) { |
| 436 | + server, store := claudeFailoverServer(t) |
| 437 | + if _, err := store.Put("claude", "session-fable", "cooked@example.com", ""); err != nil { |
| 438 | + t.Fatal(err) |
| 439 | + } |
| 440 | + stub := &stubRoundTripper{responses: func(req *http.Request) *http.Response { |
| 441 | + h := http.Header{} |
| 442 | + h.Set("Anthropic-Ratelimit-Unified-Status", "rejected") |
| 443 | + return &http.Response{ |
| 444 | + StatusCode: http.StatusTooManyRequests, |
| 445 | + Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}`)), |
| 446 | + Header: h, |
| 447 | + } |
| 448 | + }} |
| 449 | + var logBuf bytes.Buffer |
| 450 | + logger := slog.New(slog.NewTextHandler(&logBuf, nil)) |
| 451 | + fableBody := []byte(`{"model":"claude-fable-5","max_tokens":8,"messages":[]}`) |
| 452 | + req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(fableBody)) |
| 453 | + if err != nil { |
| 454 | + t.Fatal(err) |
| 455 | + } |
| 456 | + req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(fableBody)), nil } |
| 457 | + req.Header.Set("Authorization", "Bearer tok-cooked") |
| 458 | + transport := usageLimitRetryTransport{ |
| 459 | + base: stub, |
| 460 | + server: &server, |
| 461 | + logger: logger, |
| 462 | + provider: accounts.ProviderClaude, |
| 463 | + agent: "claude", |
| 464 | + session: "session-fable", |
| 465 | + account: "cooked@example.com", |
| 466 | + method: http.MethodPost, |
| 467 | + path: "/v1/messages", |
| 468 | + maxAttempts: 4, |
| 469 | + fableFallback: func() (*http.Response, bool) { |
| 470 | + return nil, false |
| 471 | + }, |
| 472 | + } |
| 473 | + response, err := transport.RoundTrip(req) |
| 474 | + if err != nil { |
| 475 | + t.Fatal(err) |
| 476 | + } |
| 477 | + defer response.Body.Close() |
| 478 | + if response.StatusCode != http.StatusTooManyRequests { |
| 479 | + t.Fatalf("status = %d, want pool 429", response.StatusCode) |
| 480 | + } |
| 481 | + logs := logBuf.String() |
| 482 | + if !strings.Contains(logs, "claude rate-limit returned to client after failover exhausted") { |
| 483 | + t.Fatalf("expected client-facing failover-exhaustion signal; logs=\n%s", logs) |
| 484 | + } |
| 485 | +} |
| 486 | + |
367 | 487 | func TestOauthRetryAccountSkipsAPIKeyAccountsWhenOAuthOnly(t *testing.T) { |
368 | 488 | server, _ := claudeFailoverServer(t) |
369 | 489 | server.Accounts = append(server.Accounts, accounts.Account{ |
|
0 commit comments