Files
krow_backend/go-api/internal/httpserver/gatewaymessage_test.go
Aravind a372340281
Some checks failed
CI / test (push) Failing after 4m41s
CI / fixture (push) Failing after 9s
add the greeting msg
2026-10-05 16:20:17 +05:30

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")
}
}