feat: record and expose terminal upstream interception errors (#26961)

Categorises the terminal error of a failed interception and persists it
on the interception record, then surfaces it on the AI Gateway API.

- Categorise into an enum (`bad_request`, `unauthorized`,
  `rate_limited`, `overloaded`, `server_error`, `unknown`), unwrapping
  the ResponseError envelope, the upstream Anthropic/OpenAI SDK errors,
  and key-pool exhaustion so blocking and streaming paths agree.
- Thread the type and raw message through the recorder dRPC into the
  `aibridge_interceptions` row (optional proto fields; NULL on success).
- Expose the error on the AI Gateway thread API from the root
  interception.

*This PR was produced by opencode (agent) using the `anthropic/claude-opus-4-8` model, under human direction and review.*
This commit is contained in:
Danny Kopping
2026-07-09 15:36:56 +02:00
committed by GitHub
parent 63497ee9d8
commit ef0b5585d5
34 changed files with 1212 additions and 322 deletions
+42
View File
@@ -258,10 +258,20 @@ func (s *Server) RecordInterceptionEnded(ctx context.Context, in *proto.RecordIn
)
}
// The error type and message form one logical unit: the terminal error.
// Gate the message on the type so the row never carries a message without a
// type (the migration treats both-NULL as a successful interception).
errType := interceptionErrorType(in.GetErrorType())
var errMsg sql.NullString
if errType.Valid && in.GetErrorMessage() != "" {
errMsg = sql.NullString{String: truncateErrorMessage(in.GetErrorMessage()), Valid: true}
}
_, err = s.store.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{
ID: intcID,
EndedAt: in.EndedAt.AsTime(),
CredentialHint: in.CredentialHint,
ErrorType: errType,
ErrorMessage: errMsg,
})
if err != nil {
return nil, xerrors.Errorf("end interception: %w", err)
@@ -994,6 +1004,38 @@ func credentialKindOrDefault(kind string) database.CredentialKind {
return ck
}
// maxErrorMessageBytes caps the interception error message stored in the
// database, enforced at this trust boundary regardless of caller behavior.
const maxErrorMessageBytes = 1024
// truncateErrorMessage caps msg to maxErrorMessageBytes, dropping any partial
// trailing rune so the stored value stays valid UTF-8.
func truncateErrorMessage(msg string) string {
if len(msg) <= maxErrorMessageBytes {
return msg
}
return strings.ToValidUTF8(msg[:maxErrorMessageBytes], "")
}
// interceptionErrorType maps the wire error type onto the nullable DB enum. An
// empty value yields NULL (a successful interception). A non-empty but
// unrecognized value (e.g. version skew where the client knows an enum the DB
// migration does not yet) is stored as 'unknown' rather than NULL, so the row's
// error columns stay consistent with a recorded error_message.
func interceptionErrorType(errType string) database.NullAIBridgeInterceptionErrorType {
if errType == "" {
return database.NullAIBridgeInterceptionErrorType{}
}
et := database.AIBridgeInterceptionErrorType(errType)
if !et.Valid() {
et = database.AibridgeInterceptionErrorTypeUnknown
}
return database.NullAIBridgeInterceptionErrorType{
AIBridgeInterceptionErrorType: et,
Valid: true,
}
}
func metadataToMap(in map[string]*anypb.Any) map[string]any {
meta := make(map[string]any, len(in))
for k, v := range in {
@@ -1454,6 +1454,74 @@ func TestRecordInterceptionEnded(t *testing.T) {
}, nil)
},
},
{
name: "ok_with_error",
request: &proto.RecordInterceptionEndedRequest{
Id: uuid.UUID{1}.String(),
EndedAt: timestamppb.Now(),
ErrorType: protobufproto.String(string(database.AibridgeInterceptionErrorTypeRateLimited)),
ErrorMessage: protobufproto.String("rate limited by upstream"),
},
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) {
interceptionID, err := uuid.Parse(req.GetId())
assert.NoError(t, err, "parse interception UUID")
db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{
ID: interceptionID,
EndedAt: req.EndedAt.AsTime(),
ErrorType: database.NullAIBridgeInterceptionErrorType{
AIBridgeInterceptionErrorType: database.AIBridgeInterceptionErrorType(req.GetErrorType()),
Valid: true,
},
ErrorMessage: sql.NullString{String: req.GetErrorMessage(), Valid: true},
}).Return(database.AIBridgeInterception{ID: interceptionID}, nil)
},
},
{
name: "invalid_error_type_is_unknown",
request: &proto.RecordInterceptionEndedRequest{
Id: uuid.UUID{1}.String(),
EndedAt: timestamppb.Now(),
ErrorType: protobufproto.String("not-a-real-type"),
},
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) {
interceptionID, err := uuid.Parse(req.GetId())
assert.NoError(t, err, "parse interception UUID")
// A non-empty but unrecognized error type is stored as
// 'unknown' (not NULL), keeping the error columns consistent.
db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{
ID: interceptionID,
EndedAt: req.EndedAt.AsTime(),
ErrorType: database.NullAIBridgeInterceptionErrorType{
AIBridgeInterceptionErrorType: database.AibridgeInterceptionErrorTypeUnknown,
Valid: true,
},
}).Return(database.AIBridgeInterception{ID: interceptionID}, nil)
},
},
{
name: "message_without_error_type_stores_neither",
request: &proto.RecordInterceptionEndedRequest{
Id: uuid.UUID{1}.String(),
EndedAt: timestamppb.Now(),
ErrorMessage: protobufproto.String("orphan message with no type"),
},
setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) {
interceptionID, err := uuid.Parse(req.GetId())
assert.NoError(t, err, "parse interception UUID")
// A message without a type is not a categorized error, so
// both columns stay NULL to preserve the both-NULL == success
// invariant rather than persisting a half-populated error.
db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{
ID: interceptionID,
EndedAt: req.EndedAt.AsTime(),
ErrorType: database.NullAIBridgeInterceptionErrorType{},
ErrorMessage: sql.NullString{},
}).Return(database.AIBridgeInterception{ID: interceptionID}, nil)
},
},
{
name: "bad_uuid_error",
request: &proto.RecordInterceptionEndedRequest{