Add GatewayFailure: the provider not answering is not a tool failing
terminationFor sent every gateway error that was not Refused or Timeout to ToolFailure, because the enum had nowhere else to put it. On 2026-09-22 that was 131 of 318 production runs, and not one of them was a tool failing: 56 were retired model ids, 25 an exhausted Anthropic balance, 45 Groq's free-tier rate limit -- the only one still happening. An operator reading the termination column saw "a tool is broken" for two weeks while the actual answer was "we are not paying for capacity". GatewayFailure is the seventh termination. Rate limited, request rejected, credential refused and unreachable land there; Refused and Deadline keep their own reasons; a non-gateway error is still the tool layer's. A delegation whose subagent died at the gateway now carries that reason up to the parent instead of reading as a tool call that failed. Migration 000016 widens the CHECK that 000006 chose precisely so this would be a migration rather than an ALTER TYPE. Its down folds any GatewayFailure rows back to ToolFailure BEFORE narrowing the constraint, which is the order that works; verified up, down and up again on a scratch database. Existing rows are left as they are -- the trajectory entries still carry the gateway.* code for anyone reclassifying history. The surface wording is the one termination where "try again" is honest advice, since the dominant cause clears within a minute. Full suite run against a real database, including the tests that skip without one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
@@ -277,6 +277,7 @@ func TestTerminationValidRejectsInvented(t *testing.T) {
|
||||
for _, ok := range []Termination{
|
||||
TerminationCompleted, TerminationBudgetExceeded, TerminationDeadline,
|
||||
TerminationConfirmationPending, TerminationToolFailure, TerminationRefused,
|
||||
TerminationGatewayFailure,
|
||||
} {
|
||||
if !ok.Valid() {
|
||||
t.Errorf("%q should be a valid termination", ok)
|
||||
@@ -1075,3 +1076,31 @@ func TestTheTrajectoryRecordsWhichChunksGroundedTheAnswer(t *testing.T) {
|
||||
t.Error("nothing in the trajectory says a retrieval happened")
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reason GatewayFailure exists: a provider that will not answer is
|
||||
// not a tool that broke, and for two weeks the trajectory said it was.
|
||||
func TestTerminationForSeparatesGatewayFromTool(t *testing.T) {
|
||||
gw := func(code string) error { return &gateway.Error{Code: code, Message: "x"} }
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want Termination
|
||||
}{
|
||||
{"rate limited is the gateway's", gw(gateway.CodeRateLimited), TerminationGatewayFailure},
|
||||
{"invalid request is the gateway's", gw(gateway.CodeInvalidRequest), TerminationGatewayFailure},
|
||||
{"bad credential is the gateway's", gw(gateway.CodeUnauthorized), TerminationGatewayFailure},
|
||||
{"upstream is the gateway's", gw(gateway.CodeUpstream), TerminationGatewayFailure},
|
||||
{"not configured is the gateway's", gw(gateway.CodeNotConfigured), TerminationGatewayFailure},
|
||||
{"refused keeps its own reason", gw(gateway.CodeRefused), TerminationRefused},
|
||||
{"timeout keeps its own reason", gw(gateway.CodeTimeout), TerminationDeadline},
|
||||
{"a non-gateway error is still the tool layer's", errors.New("tool exploded"), TerminationToolFailure},
|
||||
{"a wrapped gateway error is still found", fmt.Errorf("delegating: %w", gw(gateway.CodeRateLimited)), TerminationGatewayFailure},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := terminationFor(tc.err); got != tc.want {
|
||||
t.Errorf("terminationFor(%v) = %s, want %s", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user