339 lines
12 KiB
Go
339 lines
12 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
func TestManager_MarkResult_ConnectionLifecycleDoesNotCooldown(t *testing.T) {
|
|
previous := quotaCooldownDisabled.Load()
|
|
quotaCooldownDisabled.Store(false)
|
|
t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
|
|
|
|
prevTransient := transientErrorCooldownSeconds.Load()
|
|
SetTransientErrorCooldownSeconds(5)
|
|
t.Cleanup(func() { transientErrorCooldownSeconds.Store(prevTransient) })
|
|
|
|
cases := []struct {
|
|
name string
|
|
err *Error
|
|
}{
|
|
{name: "websocket 1000", err: &Error{Message: "websocket: close 1000 (normal)"}},
|
|
{name: "websocket 1001", err: &Error{Message: "websocket: close 1001 (going away)"}},
|
|
{name: "websocket 1006", err: &Error{Message: "websocket: close 1006 (abnormal closure): unexpected EOF"}},
|
|
{name: "context canceled", err: &Error{Message: "context canceled"}},
|
|
{name: "context deadline exceeded", err: &Error{Message: "context deadline exceeded"}},
|
|
{name: "unexpected EOF", err: &Error{Message: "unexpected EOF"}},
|
|
{name: "plain EOF", err: &Error{Message: "EOF"}},
|
|
{name: "wrapped unexpected EOF", err: &Error{Message: "read tcp 127.0.0.1:1->127.0.0.1:2: unexpected EOF"}},
|
|
{name: "typed canceled", err: resultErrorFromError(context.Canceled)},
|
|
{name: "typed deadline", err: resultErrorFromError(context.DeadlineExceeded)},
|
|
{name: "url canceled", err: resultErrorFromError(&url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled})},
|
|
{name: "url deadline", err: resultErrorFromError(&url.Error{Op: "Post", URL: "https://example.com", Err: context.DeadlineExceeded})},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := NewManager(nil, nil, nil)
|
|
auth := &Auth{ID: "auth-lifecycle-" + tc.name, Provider: "codex"}
|
|
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("register auth: %v", errRegister)
|
|
}
|
|
|
|
model := "gpt-5.6-sol"
|
|
m.MarkResult(context.Background(), Result{
|
|
AuthID: auth.ID,
|
|
Provider: auth.Provider,
|
|
Model: model,
|
|
Success: false,
|
|
Error: tc.err,
|
|
})
|
|
|
|
assertNoCooldown(t, m, auth.ID, model)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManager_MarkResult_ConnectionLifecycleAuthLevelDoesNotCooldown(t *testing.T) {
|
|
previous := quotaCooldownDisabled.Load()
|
|
quotaCooldownDisabled.Store(false)
|
|
t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
|
|
|
|
prevTransient := transientErrorCooldownSeconds.Load()
|
|
SetTransientErrorCooldownSeconds(5)
|
|
t.Cleanup(func() { transientErrorCooldownSeconds.Store(prevTransient) })
|
|
|
|
m := NewManager(nil, nil, nil)
|
|
auth := &Auth{ID: "auth-lifecycle-auth-level", Provider: "codex"}
|
|
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("register auth: %v", errRegister)
|
|
}
|
|
|
|
m.MarkResult(context.Background(), Result{
|
|
AuthID: auth.ID,
|
|
Provider: auth.Provider,
|
|
// Empty model exercises the auth-level failure path.
|
|
Success: false,
|
|
Error: &Error{Message: "websocket: close 1006 (abnormal closure): unexpected EOF"},
|
|
})
|
|
|
|
updated, ok := m.GetByID(auth.ID)
|
|
if !ok || updated == nil {
|
|
t.Fatalf("expected auth to be present")
|
|
}
|
|
if updated.Unavailable {
|
|
t.Fatalf("expected auth-level lifecycle error to keep auth available")
|
|
}
|
|
if !updated.NextRetryAfter.IsZero() {
|
|
t.Fatalf("expected auth-level lifecycle error to keep auth cooldown unset, got %v", updated.NextRetryAfter)
|
|
}
|
|
}
|
|
|
|
func TestManager_MarkResult_HTTPStatusWithLifecycleTextStillCooldowns(t *testing.T) {
|
|
previous := quotaCooldownDisabled.Load()
|
|
quotaCooldownDisabled.Store(false)
|
|
t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
|
|
|
|
prevTransient := transientErrorCooldownSeconds.Load()
|
|
SetTransientErrorCooldownSeconds(5)
|
|
t.Cleanup(func() { transientErrorCooldownSeconds.Store(prevTransient) })
|
|
|
|
cases := []struct {
|
|
name string
|
|
httpStatus int
|
|
message string
|
|
wantAuth bool // true => long auth-style suspension reason expected via model state
|
|
}{
|
|
{name: "401 unexpected EOF", httpStatus: http.StatusUnauthorized, message: "unexpected EOF", wantAuth: true},
|
|
{name: "429 context canceled", httpStatus: http.StatusTooManyRequests, message: "context canceled", wantAuth: true},
|
|
{name: "500 unexpected EOF", httpStatus: http.StatusInternalServerError, message: "unexpected EOF"},
|
|
{name: "500 websocket 1006 text", httpStatus: http.StatusInternalServerError, message: "websocket: close 1006 (abnormal closure): unexpected EOF"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := NewManager(nil, nil, nil)
|
|
auth := &Auth{ID: "auth-status-" + tc.name, Provider: "codex"}
|
|
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("register auth: %v", errRegister)
|
|
}
|
|
|
|
model := "gpt-5.6-sol"
|
|
before := time.Now()
|
|
m.MarkResult(context.Background(), Result{
|
|
AuthID: auth.ID,
|
|
Provider: auth.Provider,
|
|
Model: model,
|
|
Success: false,
|
|
Error: &Error{
|
|
HTTPStatus: tc.httpStatus,
|
|
Message: tc.message,
|
|
},
|
|
})
|
|
|
|
updated, ok := m.GetByID(auth.ID)
|
|
if !ok || updated == nil {
|
|
t.Fatalf("expected auth to be present")
|
|
}
|
|
state := updated.ModelStates[model]
|
|
if state == nil {
|
|
t.Fatal("expected model cooldown state")
|
|
}
|
|
if state.NextRetryAfter.IsZero() {
|
|
t.Fatalf("expected HTTP status %d with lifecycle text to still cool, got zero NextRetryAfter", tc.httpStatus)
|
|
}
|
|
if tc.httpStatus == http.StatusInternalServerError && state.NextRetryAfter.Before(before.Add(4*time.Second)) {
|
|
t.Fatalf("expected ~5s transient cooldown, got next_retry_after=%v", state.NextRetryAfter)
|
|
}
|
|
if tc.wantAuth && !state.Unavailable {
|
|
t.Fatalf("expected auth-class status to mark model unavailable")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManager_MarkResult_NonLifecycleStillCooldowns(t *testing.T) {
|
|
previous := quotaCooldownDisabled.Load()
|
|
quotaCooldownDisabled.Store(false)
|
|
t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
|
|
|
|
prevTransient := transientErrorCooldownSeconds.Load()
|
|
SetTransientErrorCooldownSeconds(5)
|
|
t.Cleanup(func() { transientErrorCooldownSeconds.Store(prevTransient) })
|
|
|
|
m := NewManager(nil, nil, nil)
|
|
auth := &Auth{ID: "auth-still-cools", Provider: "codex"}
|
|
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("register auth: %v", errRegister)
|
|
}
|
|
|
|
model := "gpt-5.6-sol"
|
|
before := time.Now()
|
|
m.MarkResult(context.Background(), Result{
|
|
AuthID: auth.ID,
|
|
Provider: auth.Provider,
|
|
Model: model,
|
|
Success: false,
|
|
Error: &Error{
|
|
HTTPStatus: http.StatusInternalServerError,
|
|
Message: "upstream internal failure",
|
|
Retryable: true,
|
|
},
|
|
})
|
|
|
|
updated, ok := m.GetByID(auth.ID)
|
|
if !ok || updated == nil {
|
|
t.Fatalf("expected auth to be present")
|
|
}
|
|
state := updated.ModelStates[model]
|
|
if state == nil {
|
|
t.Fatal("expected model cooldown state")
|
|
}
|
|
if !state.Unavailable {
|
|
t.Fatal("expected non-lifecycle 500 to mark model unavailable")
|
|
}
|
|
if state.NextRetryAfter.Before(before.Add(4 * time.Second)) {
|
|
t.Fatalf("expected ~5s transient cooldown, got next_retry_after=%v", state.NextRetryAfter)
|
|
}
|
|
}
|
|
|
|
func TestResultErrorFromError_ConnectionLifecycleDoesNotBecomeRequestScoped(t *testing.T) {
|
|
cases := []error{
|
|
context.Canceled,
|
|
context.DeadlineExceeded,
|
|
io.EOF,
|
|
io.ErrUnexpectedEOF,
|
|
&url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled},
|
|
&url.Error{Op: "Post", URL: "https://example.com", Err: context.DeadlineExceeded},
|
|
&websocket.CloseError{Code: websocket.CloseNormalClosure, Text: "normal"},
|
|
&websocket.CloseError{Code: websocket.CloseGoingAway, Text: "bye"},
|
|
&websocket.CloseError{Code: websocket.CloseAbnormalClosure, Text: "unexpected EOF"},
|
|
fmt.Errorf("upstream read: %w", &websocket.CloseError{Code: websocket.CloseAbnormalClosure, Text: "unexpected EOF"}),
|
|
fmt.Errorf("wrap: %w", io.ErrUnexpectedEOF),
|
|
errors.New("websocket: close 1000 (normal)"),
|
|
errors.New("websocket: close 1006 (abnormal closure): unexpected EOF"),
|
|
errors.New("context deadline exceeded"),
|
|
errors.New("unexpected EOF"),
|
|
}
|
|
for _, err := range cases {
|
|
if !isConnectionLifecycleError(err) {
|
|
t.Fatalf("isConnectionLifecycleError(%v) = false, want true", err)
|
|
}
|
|
got := resultErrorFromError(err)
|
|
if got == nil {
|
|
t.Fatalf("resultErrorFromError(%v) = nil", err)
|
|
}
|
|
if got.IsRequestScoped() {
|
|
t.Fatalf("resultErrorFromError(%v) code=%q, want non-request-scoped lifecycle error", err, got.Code)
|
|
}
|
|
if got.Code != connectionLifecycleErrorCode {
|
|
t.Fatalf("resultErrorFromError(%v) code=%q, want %q", err, got.Code, connectionLifecycleErrorCode)
|
|
}
|
|
if isRequestInvalidError(err) {
|
|
t.Fatalf("isRequestInvalidError(%v) = true, lifecycle must not stop credential fallback", err)
|
|
}
|
|
if !shouldSkipCredentialCooldown(got) {
|
|
t.Fatalf("shouldSkipCredentialCooldown(%#v) = false, want true", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsConnectionLifecycleError_StatusBearingErrorsStayCoolable(t *testing.T) {
|
|
cases := []error{
|
|
&statusBearingError{status: http.StatusUnauthorized, msg: "unexpected EOF"},
|
|
&statusBearingError{status: http.StatusTooManyRequests, msg: "context canceled"},
|
|
&statusBearingError{status: http.StatusInternalServerError, msg: "unexpected EOF"},
|
|
&statusBearingError{status: http.StatusBadGateway, msg: "websocket: close 1006 (abnormal closure): unexpected EOF"},
|
|
}
|
|
for _, err := range cases {
|
|
if isConnectionLifecycleError(err) {
|
|
t.Fatalf("isConnectionLifecycleError(%v) = true, want false for status-bearing errors", err)
|
|
}
|
|
got := resultErrorFromError(err)
|
|
if shouldSkipCredentialCooldown(got) {
|
|
t.Fatalf("shouldSkipCredentialCooldown(%#v) = true, want false", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsConnectionLifecycleError_TypedCloseWins(t *testing.T) {
|
|
// Typed websocket close is unambiguous even when an outer status is attached.
|
|
err := &statusBearingCloseError{
|
|
status: http.StatusBadGateway,
|
|
close: &websocket.CloseError{Code: websocket.CloseAbnormalClosure, Text: "unexpected EOF"},
|
|
}
|
|
if !isConnectionLifecycleError(err) {
|
|
t.Fatalf("typed CloseError should be lifecycle even with outer status")
|
|
}
|
|
got := resultErrorFromError(err)
|
|
if got.Code != connectionLifecycleErrorCode {
|
|
t.Fatalf("code = %q, want %q", got.Code, connectionLifecycleErrorCode)
|
|
}
|
|
if !shouldSkipCredentialCooldown(got) {
|
|
t.Fatalf("shouldSkipCredentialCooldown(%#v) = false, want true", got)
|
|
}
|
|
|
|
m := NewManager(nil, nil, nil)
|
|
auth := &Auth{ID: "auth-typed-close", Provider: "codex"}
|
|
if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("register auth: %v", errRegister)
|
|
}
|
|
model := "gpt-5.6-sol"
|
|
m.MarkResult(context.Background(), Result{
|
|
AuthID: auth.ID,
|
|
Provider: auth.Provider,
|
|
Model: model,
|
|
Success: false,
|
|
Error: got,
|
|
})
|
|
assertNoCooldown(t, m, auth.ID, model)
|
|
}
|
|
|
|
type statusBearingError struct {
|
|
status int
|
|
msg string
|
|
}
|
|
|
|
func (e *statusBearingError) Error() string { return e.msg }
|
|
func (e *statusBearingError) StatusCode() int { return e.status }
|
|
|
|
type statusBearingCloseError struct {
|
|
status int
|
|
close *websocket.CloseError
|
|
}
|
|
|
|
func (e *statusBearingCloseError) Error() string {
|
|
if e.close == nil {
|
|
return "status-bearing close"
|
|
}
|
|
return e.close.Error()
|
|
}
|
|
func (e *statusBearingCloseError) StatusCode() int { return e.status }
|
|
func (e *statusBearingCloseError) Unwrap() error { return e.close }
|
|
|
|
func assertNoCooldown(t *testing.T, m *Manager, authID, model string) {
|
|
t.Helper()
|
|
updated, ok := m.GetByID(authID)
|
|
if !ok || updated == nil {
|
|
t.Fatalf("expected auth to be present")
|
|
}
|
|
if updated.Unavailable {
|
|
t.Fatalf("expected connection lifecycle error to keep auth available")
|
|
}
|
|
if !updated.NextRetryAfter.IsZero() {
|
|
t.Fatalf("expected connection lifecycle error to keep auth cooldown unset, got %v", updated.NextRetryAfter)
|
|
}
|
|
if state := updated.ModelStates[model]; state != nil {
|
|
if state.Unavailable || !state.NextRetryAfter.IsZero() {
|
|
t.Fatalf("expected no model cooldown, got %#v", state)
|
|
}
|
|
}
|
|
}
|