mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user