172 lines
5.5 KiB
Go
172 lines
5.5 KiB
Go
package httpserver
|
|
|
|
// Unit tests for the operator's side of a GatewayFailure.
|
|
//
|
|
// The user-facing sentence tells the reader whether retrying can work. These
|
|
// assert the other half: that the deployment says WHICH fault it was, to the
|
|
// only audience that can act on it. Four of the five gateway faults need an
|
|
// administrator, and until this line existed a deployment failing every run
|
|
// emitted a stream of 200s and nothing else.
|
|
//
|
|
// Internal rather than httpserver_test because the function under test is
|
|
// unexported. Pure: a result goes in and a log record comes out — no server
|
|
// wiring, no database.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
"github.com/krow/krow-backend/go-api/internal/gateway"
|
|
"github.com/krow/krow-backend/go-api/internal/runtime"
|
|
)
|
|
|
|
// logging builds a Server that logs into a buffer, and a reader for the records
|
|
// it wrote.
|
|
func logging(t *testing.T) (*Server, func() []map[string]any) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
s := &Server{log: slog.New(slog.NewJSONHandler(&buf, nil))}
|
|
|
|
return s, func() []map[string]any {
|
|
var out []map[string]any
|
|
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var rec map[string]any
|
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
|
t.Fatalf("log line is not JSON: %v", err)
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
func gatewayResult(term runtime.Termination, cause error) *runtime.ExecutionResult {
|
|
return &runtime.ExecutionResult{
|
|
RunID: "run_1",
|
|
AgentID: "activity-agent",
|
|
AgentVersion: 3,
|
|
Termination: term,
|
|
Error: &runtime.RuntimeError{
|
|
Code: "runtime." + strings.ToLower(string(term)),
|
|
Message: "internal wording",
|
|
Cause: cause,
|
|
},
|
|
}
|
|
}
|
|
|
|
// The code is what distinguishes the retryable fault from the four that need an
|
|
// administrator, so it is the field that must survive into the log.
|
|
func TestGatewayFailureIsLoggedWithItsCode(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
cause error
|
|
wantCode string
|
|
wantStatus float64
|
|
}{
|
|
{
|
|
name: "no credential configured",
|
|
cause: &gateway.Error{Code: gateway.CodeNotConfigured, Message: "no model credentials"},
|
|
wantCode: gateway.CodeNotConfigured,
|
|
},
|
|
{
|
|
name: "credential rejected",
|
|
cause: &gateway.Error{Code: gateway.CodeUnauthorized, Message: "refused", Status: 401},
|
|
wantCode: gateway.CodeUnauthorized,
|
|
wantStatus: 401,
|
|
},
|
|
{
|
|
name: "rate limited",
|
|
cause: &gateway.Error{Code: gateway.CodeRateLimited, Message: "slow down", Status: 429},
|
|
wantCode: gateway.CodeRateLimited,
|
|
wantStatus: 429,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s, records := logging(t)
|
|
s.logGatewayFailure(authctx.Identity{OrgID: "org_1"},
|
|
gatewayResult(runtime.TerminationGatewayFailure, tc.cause))
|
|
|
|
recs := records()
|
|
if len(recs) != 1 {
|
|
t.Fatalf("wrote %d log records, want 1: %v", len(recs), recs)
|
|
}
|
|
rec := recs[0]
|
|
|
|
if rec["level"] != "ERROR" {
|
|
t.Errorf("level = %v, want ERROR — a deployment that cannot reach its model is an outage", rec["level"])
|
|
}
|
|
if got := rec["gateway_code"]; got != tc.wantCode {
|
|
t.Errorf("gateway_code = %v, want %q", got, tc.wantCode)
|
|
}
|
|
if got := rec["gateway_status"]; got != tc.wantStatus {
|
|
t.Errorf("gateway_status = %v, want %v", got, tc.wantStatus)
|
|
}
|
|
|
|
// §10: every line carries these four.
|
|
for _, field := range []string{"run_id", "tenant_id", "agent_key", "agent_version"} {
|
|
if rec[field] == nil {
|
|
t.Errorf("log record has no %s", field)
|
|
}
|
|
}
|
|
// §10 again: no model or document text in the log store. The
|
|
// gateway's message can quote the provider's body, so it stays out.
|
|
if strings.Contains(strings.ToLower(rec["msg"].(string)), "refused") {
|
|
t.Errorf("msg = %q, want no provider text", rec["msg"])
|
|
}
|
|
for k, v := range rec {
|
|
if str, ok := v.(string); ok && strings.Contains(str, "slow down") {
|
|
t.Errorf("field %s leaked the provider message: %q", k, str)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A cause that is not a gateway error leaves the code visibly empty rather than
|
|
// guessed. "Which fault was it" is the whole point of the line, and a wrong
|
|
// answer to it is worse than a gap.
|
|
func TestGatewayFailureWithoutACauseLogsAnEmptyCode(t *testing.T) {
|
|
s, records := logging(t)
|
|
s.logGatewayFailure(authctx.Identity{OrgID: "org_1"},
|
|
gatewayResult(runtime.TerminationGatewayFailure, nil))
|
|
|
|
recs := records()
|
|
if len(recs) != 1 {
|
|
t.Fatalf("wrote %d log records, want 1", len(recs))
|
|
}
|
|
if got := recs[0]["gateway_code"]; got != "" {
|
|
t.Errorf("gateway_code = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
// Every other termination is silent here. A run that hit its budget or was
|
|
// refused is not a gateway outage, and logging it as one would make the signal
|
|
// useless exactly when it is being read.
|
|
func TestOnlyGatewayFailureIsLogged(t *testing.T) {
|
|
for _, term := range []runtime.Termination{
|
|
runtime.TerminationCompleted,
|
|
runtime.TerminationBudgetExceeded,
|
|
runtime.TerminationDeadline,
|
|
runtime.TerminationConfirmationPending,
|
|
runtime.TerminationToolFailure,
|
|
runtime.TerminationRefused,
|
|
} {
|
|
t.Run(string(term), func(t *testing.T) {
|
|
s, records := logging(t)
|
|
s.logGatewayFailure(authctx.Identity{OrgID: "org_1"},
|
|
gatewayResult(term, &gateway.Error{Code: gateway.CodeRateLimited}))
|
|
|
|
if recs := records(); len(recs) != 0 {
|
|
t.Errorf("wrote %d log records for %s, want none: %v", len(recs), term, recs)
|
|
}
|
|
})
|
|
}
|
|
}
|