171 lines
5.7 KiB
Go
171 lines
5.7 KiB
Go
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")
|
|
}
|
|
}
|