package httpserver // Unit tests for the wording a GatewayFailure produces. // // Internal rather than httpserver_test because the function under test is the // mapping itself, and the mapping is unexported. Pure: no server, no database, // no fixture — a cause goes in and a sentence comes out. // // What these assert is one property, and it is the one the old wording broke: // a reader is told to retry EXACTLY when retrying can work. A rate limit clears // on its own; a rejected credential, a model id the endpoint does not have, and // an unconfigured deployment do not, and telling somebody to wait a minute for // any of those is a loop with no exit. import ( "errors" "strings" "testing" "github.com/krow/krow-backend/go-api/internal/gateway" "github.com/krow/krow-backend/go-api/internal/runtime" ) // invitesRetry reports whether a sentence tells the reader to try again. // // Deliberately looser than an equality check on the whole string: what must // hold is the ADVICE, not the copy, so rewording a sentence does not fail a // test that was never about the words. func invitesRetry(message string) bool { m := strings.ToLower(message) return strings.Contains(m, "ask again") || strings.Contains(m, "try again") } func TestGatewayFailureMessageInvitesRetryOnlyWhenRetryingCanWork(t *testing.T) { cases := []struct { name string cause error retry bool }{ { name: "a rate limit clears on its own", cause: &gateway.Error{Code: gateway.CodeRateLimited, Status: 429}, retry: true, }, { name: "a provider 5xx is worth another attempt", cause: &gateway.Error{Code: gateway.CodeUpstream, Status: 503}, retry: true, }, { name: "a rejected credential will be rejected again", cause: &gateway.Error{Code: gateway.CodeUnauthorized, Status: 401}, retry: false, }, { name: "a model the endpoint does not have stays absent", cause: &gateway.Error{Code: gateway.CodeInvalidRequest, Status: 404}, retry: false, }, { name: "an unconfigured deployment cannot answer at all", cause: &gateway.Error{Code: gateway.CodeNotConfigured}, retry: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := gatewayFailureMessage(tc.cause) if got == "" { t.Fatal("a failed run must say something") } if invitesRetry(got) != tc.retry { t.Errorf("retry advice = %v, want %v\n message: %q", invitesRetry(got), tc.retry, got) } // Nothing ran, so nothing can have been written. The reassurance is // the whole reason this termination is not frightening. if !strings.Contains(got, "Nothing was changed") { t.Errorf("message must say nothing was changed: %q", got) } }) } } // The three that need a person are the three that used to be indistinguishable // from load. Each must point at one, or the reader has no idea what to do next. func TestGatewayFailureMessageNamesAnAdministratorWhenOneIsNeeded(t *testing.T) { for _, code := range []string{ gateway.CodeUnauthorized, gateway.CodeInvalidRequest, gateway.CodeNotConfigured, } { got := gatewayFailureMessage(&gateway.Error{Code: code}) if !strings.Contains(strings.ToLower(got), "administrator") { t.Errorf("%s: must send the reader to an administrator: %q", code, got) } } } // Vendor names, model ids and HTTP statuses are for the trajectory, not for a // venue manager — they cannot act on any of them. func TestGatewayFailureMessageLeaksNoOperatorDetail(t *testing.T) { for _, code := range []string{ gateway.CodeRateLimited, gateway.CodeUpstream, gateway.CodeUnauthorized, gateway.CodeInvalidRequest, gateway.CodeNotConfigured, } { got := gatewayFailureMessage(&gateway.Error{ Code: code, Status: 429, Message: "gemini-3.5-flash-lite quota exceeded for project 12345", }) for _, leak := range []string{"gemini", "429", "quota", "http", "12345"} { if strings.Contains(strings.ToLower(got), leak) { t.Errorf("%s: message carries operator detail %q: %q", code, leak, got) } } } } // A cause that is not a gateway error — lost, wrapped away, or a non-gateway // failure that reached this termination — still has to produce a sentence. func TestGatewayFailureMessageFallsBackWithoutAGatewayError(t *testing.T) { for _, cause := range []error{nil, errors.New("something else entirely")} { if got := gatewayFailureMessage(cause); got == "" { t.Errorf("cause %v produced no message", cause) } } } // The cause arrives wrapped in a RuntimeError, which is how the surface // actually receives it. If Unwrap ever stopped reaching the gateway error, // every failure would silently fall back to "usually it is busy" — the exact // bug this change exists to fix, reintroduced without a compile error. func TestGatewayFailureMessageReadsThroughARuntimeError(t *testing.T) { wrapped := &runtime.RuntimeError{ Code: "runtime.gatewayfailure", Cause: &gateway.Error{Code: gateway.CodeUnauthorized, Status: 401}, } got := gatewayFailureMessage(wrapped) if invitesRetry(got) { t.Errorf("a wrapped credential failure must not invite a retry: %q", got) } } // The other terminations are unchanged by the new parameter: they ignore the // cause, so passing one must not alter a single word. func TestTerminationMessageIgnoresTheCauseElsewhere(t *testing.T) { cause := &gateway.Error{Code: gateway.CodeUnauthorized} for _, term := range []runtime.Termination{ runtime.TerminationBudgetExceeded, runtime.TerminationDeadline, runtime.TerminationConfirmationPending, runtime.TerminationToolFailure, runtime.TerminationRefused, } { if terminationMessage(term, nil) != terminationMessage(term, cause) { t.Errorf("%s: wording changed with the cause", term) } } if terminationMessage(runtime.TerminationCompleted, nil) != "" { t.Error("a completed run has nothing to say") } }