| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998 |
- package handler
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "unicode/utf8"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type ingressRejectSettingRepo struct {
- service.SettingRepository
- getValueCalls int
- }
- func (r *ingressRejectSettingRepo) GetValue(context.Context, string) (string, error) {
- r.getValueCalls++
- return "", service.ErrSettingNotFound
- }
- func (r *ingressRejectSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
- r.getValueCalls++
- return map[string]string{}, nil
- }
- func (r *ingressRejectSettingRepo) Set(context.Context, string, string) error {
- return nil
- }
- type ingressRejectOpsRepo struct {
- service.OpsRepository
- insertCalls int
- entries []*service.OpsInsertErrorLogInput
- }
- func (r *ingressRejectOpsRepo) InsertErrorLog(_ context.Context, entry *service.OpsInsertErrorLogInput) (int64, error) {
- r.insertCalls++
- r.entries = append(r.entries, entry)
- return 0, nil
- }
- func (r *ingressRejectOpsRepo) BatchInsertErrorLogs(_ context.Context, entries []*service.OpsInsertErrorLogInput) (int64, error) {
- r.insertCalls++
- r.entries = append(r.entries, entries...)
- return 0, nil
- }
- func TestOpsErrorLogQueueByteBudget(t *testing.T) {
- previousBytes := opsErrorLogQueueBytes.Load()
- previousLen := opsErrorLogQueueLen.Load()
- opsErrorLogQueueBytes.Store(0)
- opsErrorLogQueueLen.Store(0)
- t.Cleanup(func() {
- opsErrorLogQueueBytes.Store(previousBytes)
- opsErrorLogQueueLen.Store(previousLen)
- })
- if !reserveOpsErrorLogQueueBytes(opsErrorLogMaxQueueBytes - 1) {
- t.Fatal("first reservation within byte budget should succeed")
- }
- if reserveOpsErrorLogQueueBytes(2) {
- t.Fatal("reservation beyond byte budget should be rejected")
- }
- if got := OpsErrorLogQueueBytes(); got != opsErrorLogMaxQueueBytes-1 {
- t.Fatalf("queued bytes = %d, want %d", got, opsErrorLogMaxQueueBytes-1)
- }
- if got := OpsErrorLogQueueLength(); got != 1 {
- t.Fatalf("queue length = %d, want 1", got)
- }
- }
- func TestEstimateOpsErrorLogJobBytesIncludesVariablePayloads(t *testing.T) {
- base := estimateOpsErrorLogJobBytes(&service.OpsInsertErrorLogInput{})
- message := "upstream message"
- detail := "upstream detail"
- events := `[{"error":"x"}]`
- entry := &service.OpsInsertErrorLogInput{
- ErrorBody: strings.Repeat("x", 1024),
- ErrorMessage: "client error",
- UserAgent: "test-agent",
- UpstreamErrorMessage: &message,
- UpstreamErrorDetail: &detail,
- UpstreamErrorsJSON: &events,
- }
- if got := estimateOpsErrorLogJobBytes(entry); got <= base+1024 {
- t.Fatalf("estimated bytes = %d, expected variable payloads above %d", got, base+1024)
- }
- }
- func resetOpsErrorLoggerStateForTest(t *testing.T) {
- t.Helper()
- opsErrorLogMu.Lock()
- ch := opsErrorLogQueue
- opsErrorLogQueue = nil
- opsErrorLogStopping = true
- opsErrorLogMu.Unlock()
- if ch != nil {
- close(ch)
- }
- opsErrorLogWorkersWg.Wait()
- opsErrorLogOnce = sync.Once{}
- opsErrorLogStopOnce = sync.Once{}
- opsErrorLogWorkersWg = sync.WaitGroup{}
- opsErrorLogMu = sync.RWMutex{}
- opsErrorLogStopping = false
- opsErrorLogQueueLen.Store(0)
- opsErrorLogEnqueued.Store(0)
- opsErrorLogDropped.Store(0)
- opsErrorLogProcessed.Store(0)
- opsErrorLogSanitized.Store(0)
- opsErrorLogLastDropLogAt.Store(0)
- opsErrorLogShutdownCh = make(chan struct{})
- opsErrorLogShutdownOnce = sync.Once{}
- opsErrorLogDrained.Store(false)
- }
- func TestEnqueueOpsErrorLog_QueueFullDrop(t *testing.T) {
- resetOpsErrorLoggerStateForTest(t)
- // 禁止 enqueueOpsErrorLog 触发 workers,使用测试队列验证满队列降级。
- opsErrorLogOnce.Do(func() {})
- opsErrorLogMu.Lock()
- opsErrorLogQueue = make(chan opsErrorLogJob, 1)
- opsErrorLogMu.Unlock()
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- entry := &service.OpsInsertErrorLogInput{ErrorPhase: "upstream", ErrorType: "upstream_error"}
- enqueueOpsErrorLog(ops, entry)
- enqueueOpsErrorLog(ops, entry)
- require.Equal(t, int64(1), OpsErrorLogEnqueuedTotal())
- require.Equal(t, int64(1), OpsErrorLogDroppedTotal())
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- }
- func TestEnqueueOpsErrorLog_EarlyReturnBranches(t *testing.T) {
- resetOpsErrorLoggerStateForTest(t)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- entry := &service.OpsInsertErrorLogInput{ErrorPhase: "upstream", ErrorType: "upstream_error"}
- // nil 入参分支
- enqueueOpsErrorLog(nil, entry)
- enqueueOpsErrorLog(ops, nil)
- require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
- // shutdown 分支
- close(opsErrorLogShutdownCh)
- enqueueOpsErrorLog(ops, entry)
- require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
- // stopping 分支
- resetOpsErrorLoggerStateForTest(t)
- opsErrorLogMu.Lock()
- opsErrorLogStopping = true
- opsErrorLogMu.Unlock()
- enqueueOpsErrorLog(ops, entry)
- require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
- // queue nil 分支(防止启动 worker 干扰)
- resetOpsErrorLoggerStateForTest(t)
- opsErrorLogOnce.Do(func() {})
- opsErrorLogMu.Lock()
- opsErrorLogQueue = nil
- opsErrorLogMu.Unlock()
- enqueueOpsErrorLog(ops, entry)
- require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
- }
- func TestOpsCaptureWriterPool_ResetOnRelease(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodGet, "/test", nil)
- writer := acquireOpsCaptureWriter(c.Writer)
- require.NotNil(t, writer)
- c.Writer.WriteHeader(http.StatusInternalServerError)
- _, err := writer.WriteString("temp-error-body")
- require.NoError(t, err)
- require.NotEmpty(t, writer.capturedBytes())
- releaseOpsCaptureWriter(writer)
- reused := acquireOpsCaptureWriter(c.Writer)
- defer releaseOpsCaptureWriter(reused)
- require.Empty(t, reused.capturedBytes(), "writer should be reset before reuse")
- }
- func TestOpsCaptureWriterPool_DropsLargeBuffers(t *testing.T) {
- state := &opsCaptureWriterState{}
- state.buf.Grow(opsCaptureWriterPoolMaxRetainedCapacity + 1)
- require.False(t, shouldPoolOpsCaptureWriterState(state))
- }
- func TestEnqueueOpsErrorLog_SanitizesAndBoundsBodyBeforeQueue(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 1)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- secret := strings.Repeat("s", service.OpsErrorLogQueueBodyMaxBytes)
- entry := &service.OpsInsertErrorLogInput{
- ErrorPhase: "request",
- ErrorType: "api_error",
- ErrorBody: `{"authorization":"Bearer ` + secret + `","message":"failed"}`,
- }
- enqueueOpsErrorLog(ops, entry)
- job := <-opsErrorLogQueue
- require.LessOrEqual(t, len(job.entry.ErrorBody), service.OpsErrorLogQueueBodyMaxBytes)
- require.NotContains(t, job.entry.ErrorBody, secret)
- require.Equal(t, int64(1), OpsErrorLogSanitizedTotal())
- }
- func TestOpsErrorLoggerMiddleware_DoesNotBreakOuterMiddlewares(t *testing.T) {
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(middleware2.Recovery())
- r.Use(middleware2.RequestLogger())
- r.Use(middleware2.Logger())
- r.GET("/v1/messages", OpsErrorLoggerMiddleware(nil), func(c *gin.Context) {
- c.Status(http.StatusNoContent)
- })
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil)
- require.NotPanics(t, func() {
- r.ServeHTTP(rec, req)
- })
- require.Equal(t, http.StatusNoContent, rec.Code)
- }
- // setupOpsErrorLogTestQueue 阻止 enqueueOpsErrorLog 启动真实 worker,改用可检查的测试队列。
- func setupOpsErrorLogTestQueue(t *testing.T, size int) {
- t.Helper()
- resetOpsErrorLoggerStateForTest(t)
- opsErrorLogOnce.Do(func() {})
- opsErrorLogMu.Lock()
- opsErrorLogQueue = make(chan opsErrorLogJob, size)
- opsErrorLogMu.Unlock()
- }
- func TestOpsErrorLoggerMiddleware_HardSkipsIngressRejection(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 4)
- gin.SetMode(gin.TestMode)
- settings := &ingressRejectSettingRepo{}
- repo := &ingressRejectOpsRepo{}
- ops := service.NewOpsService(repo, settings, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- // Construction may read unrelated runtime settings; only request-path reads matter here.
- settings.getValueCalls = 0
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.GET("/v1/messages", func(c *gin.Context) {
- middleware2.MarkIngressRejected(c, middleware2.IngressRejectInvalidAPIKey)
- c.JSON(http.StatusUnauthorized, gin.H{"code": "INVALID_API_KEY", "message": "Invalid API key"})
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil)
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusUnauthorized, w.Code)
- require.JSONEq(t, `{"code":"INVALID_API_KEY","message":"Invalid API key"}`, w.Body.String())
- require.Zero(t, settings.getValueCalls, "ingress rejection must bypass monitoring settings reads")
- require.Zero(t, repo.insertCalls, "ingress rejection must bypass inserts")
- require.Zero(t, OpsErrorLogEnqueuedTotal(), "ingress rejection must not enter the error queue")
- }
- func TestOpsErrorLoggerMiddleware_DedicatedCyberSessionBlockRecordsExactlyOnce(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 3)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- h := &OpenAIGatewayHandler{opsService: ops}
- apiKey := &service.APIKey{ID: 41, Key: "sk-dedicated-test"}
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, "gpt-test", "session-block-hash")
- c.JSON(http.StatusForbidden, gin.H{"error": gin.H{
- "type": "permission_error", "code": "session_blocked_by_cyber_policy", "message": "blocked",
- }})
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, http.StatusForbidden, recorder.Code)
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, "cyber_policy_session_blocked", job.entry.ErrorType)
- require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
- }
- func TestOpsErrorLoggerMiddleware_OrdinaryPermissionStillRecords(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.JSON(http.StatusForbidden, gin.H{"error": gin.H{
- "type": "permission_error", "code": "permission_denied", "message": "denied",
- }})
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, "permission_error", job.entry.ErrorType)
- require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
- }
- func TestOpsErrorLoggerMiddleware_RecordsRecoveredUpstreamTelemetryOutsideFailureSLA(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- repo := &ingressRejectOpsRepo{}
- ops := service.NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
- UpstreamStatusCode: http.StatusTooManyRequests,
- Message: "earlier attempt was rate limited",
- }})
- c.JSON(http.StatusOK, gin.H{"status": "completed"})
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, http.StatusOK, recorder.Code)
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Nil(t, job.entry.UpstreamErrors, "raw attempts must be released before async queueing")
- require.NotNil(t, job.entry.UpstreamErrorsJSON)
- queuedEvents, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
- require.NoError(t, err)
- require.Len(t, queuedEvents, 1)
- require.Equal(t, http.StatusTooManyRequests, queuedEvents[0].UpstreamStatusCode)
- flushOpsErrorLogBatch([]opsErrorLogJob{job})
- require.Equal(t, 1, repo.insertCalls)
- require.Len(t, repo.entries, 1)
- persisted := repo.entries[0]
- require.Equal(t, http.StatusOK, persisted.StatusCode, "recovered telemetry must remain outside failed-request SLA")
- require.Equal(t, "upstream", persisted.ErrorPhase)
- require.Equal(t, "upstream_error", persisted.ErrorType)
- require.Equal(t, "Recovered upstream error 429: earlier attempt was rate limited", persisted.ErrorMessage)
- require.NotNil(t, persisted.UpstreamErrorsJSON)
- persistedEvents, err := service.ParseOpsUpstreamErrors(*persisted.UpstreamErrorsJSON)
- require.NoError(t, err)
- require.Len(t, persistedEvents, 1)
- require.Equal(t, http.StatusTooManyRequests, persistedEvents[0].UpstreamStatusCode)
- }
- func TestOpsErrorLoggerMiddleware_RecoveredTelemetryFiltersSkipMonitoringAttempts(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
- {UpstreamStatusCode: http.StatusTooManyRequests, Message: "visible retry"},
- {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true},
- })
- c.JSON(http.StatusOK, gin.H{"status": "completed"})
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, "Recovered upstream error 429: visible retry", job.entry.ErrorMessage)
- require.NotNil(t, job.entry.UpstreamErrorsJSON)
- events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
- require.NoError(t, err)
- require.Len(t, events, 1)
- require.Equal(t, "visible retry", events[0].Message)
- }
- func TestOpsErrorLoggerMiddleware_RecoveredTelemetrySkipsAllHiddenAttempts(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
- UpstreamStatusCode: http.StatusTooManyRequests,
- Message: "hidden retry",
- SkipMonitoring: true,
- }})
- c.JSON(http.StatusOK, gin.H{"status": "completed"})
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, int64(0), OpsErrorLogQueueLength())
- }
- func TestOpsErrorLoggerMiddleware_IntermediateSkipMonitoringDoesNotHideFinalVisibleFailure(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
- {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true},
- {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"},
- })
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{"type": "upstream_error", "message": "visible final"}})
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode)
- require.Equal(t, "visible final", job.entry.ErrorMessage)
- }
- func TestOpsErrorLoggerMiddleware_CapturesSplitResponsesFailedSSE(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- setOpsRequestContext(c, "gpt-5.5", true)
- c.Status(http.StatusOK)
- _, _ = c.Writer.Write([]byte("event: response."))
- _, _ = c.Writer.Write([]byte("failed\n"))
- _, _ = c.Writer.Write([]byte(`data: {"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"Too many pending requests"}}}`))
- _, _ = c.Writer.Write([]byte("\n\n"))
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, http.StatusOK, recorder.Code)
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode)
- require.Equal(t, "rate_limit_error", job.entry.ErrorType)
- require.Contains(t, job.entry.ErrorMessage, "Too many pending requests")
- }
- func TestOpsCaptureWriter_CapturesSplitDataOnlyTerminalMarkers(t *testing.T) {
- tests := []struct {
- name string
- prefix string
- suffix string
- wantType string
- wantCode string
- wantError string
- }{
- {
- name: "response failed with space",
- prefix: `data: {"type":"response.`,
- suffix: `failed","response":{"error":{"code":"server_is_overloaded","message":"busy"}}}`,
- wantType: "overloaded_error",
- wantCode: "server_is_overloaded",
- wantError: "busy",
- },
- {
- name: "response failed without space",
- prefix: `data:{"type":"response.`,
- suffix: `failed","error":{"code":"rate_limit_exceeded","message":"slow down"}}`,
- wantType: "rate_limit_error",
- wantCode: "rate_limit_exceeded",
- wantError: "slow down",
- },
- {
- name: "error with space",
- prefix: `data: {"type":"er`,
- suffix: `ror","error":{"type":"invalid_request_error","code":"invalid_request","message":"bad input"}}`,
- wantType: "invalid_request_error",
- wantCode: "invalid_request",
- wantError: "bad input",
- },
- {
- name: "error without space",
- prefix: `data:{"type":"er`,
- suffix: `ror","error":{"type":"authentication_error","code":"authentication_failed","message":"sign in"}}`,
- wantType: "authentication_error",
- wantCode: "authentication_failed",
- wantError: "sign in",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
- state.captureResponseChunk([]byte(tt.prefix), http.StatusOK)
- require.Empty(t, state.buf.Bytes(), "partial frame must remain in the bounded probe")
- state.captureResponseChunk([]byte(tt.suffix+"\n\n"), http.StatusOK)
- parsed := parseOpsErrorResponse(state.buf.Bytes())
- require.True(t, state.sseCapturing)
- require.True(t, parsed.StreamFailure)
- require.Equal(t, tt.wantType, parsed.ErrorType)
- require.Equal(t, tt.wantCode, parsed.Code)
- require.Equal(t, tt.wantError, parsed.Message)
- require.LessOrEqual(t, len(state.probe), opsTerminalSSEFrameProbeLimit)
- })
- }
- }
- func TestOpsErrorLoggerMiddleware_StreamFailureUsesTerminalErrorOverAttemptContext(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- service.SetOpsUpstreamError(c, http.StatusBadGateway, "Upstream transport error", "earlier attempt failed")
- c.Status(http.StatusOK)
- _, _ = c.Writer.WriteString("event: er")
- _, _ = c.Writer.WriteString("ror\n")
- _, _ = c.Writer.WriteString(`data: {"type":"error","error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"input exceeds the context window"}}`)
- _, _ = c.Writer.WriteString("\n\n")
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, http.StatusOK, recorder.Code)
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, http.StatusBadRequest, job.entry.StatusCode)
- require.Equal(t, "invalid_request_error", job.entry.ErrorType)
- require.NotNil(t, job.entry.UpstreamStatusCode)
- require.Equal(t, http.StatusBadRequest, *job.entry.UpstreamStatusCode)
- require.NotNil(t, job.entry.UpstreamErrorMessage)
- require.Equal(t, "input exceeds the context window", *job.entry.UpstreamErrorMessage)
- }
- func TestOpsErrorLoggerMiddleware_PrefersContextRequestID(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.Header("X-Request-Id", "response-header-id")
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": "bad input"}})
- })
- recorder := httptest.NewRecorder()
- request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- request = request.WithContext(context.WithValue(request.Context(), ctxkey.RequestID, "context-request-id"))
- router.ServeHTTP(recorder, request)
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, "context-request-id", job.entry.RequestID)
- }
- func TestNormalizeOpsPersistentUserAgentBoundsAndPreservesUTF8(t *testing.T) {
- value := strings.Repeat("a", opsErrorLogMaxUserAgentBytes-1) + "你" + strings.Repeat("b", 32)
- got := normalizeOpsPersistentUserAgent(" " + value + " ")
- require.LessOrEqual(t, len(got), opsErrorLogMaxUserAgentBytes)
- require.True(t, utf8.ValidString(got))
- require.NotContains(t, got, "b")
- }
- // 就地(in-band) SSE 错误挂在已固化的 HTTP 200 流上:wire 状态码为 200,
- // 常规 status>=400 采集路径不会触发。logOpsStreamError 必须据 MarkOpsStreamError
- // 补记一条错误日志,且用 IntendedStatus(429) 分级、StatusCode 仍记 wire 的 200。
- func TestLogOpsStreamError_RecordsInBandConcurrencyLimit(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 4)
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- c.Set(opsModelKey, "test-model")
- service.MarkOpsStreamError(c, "rate_limit_error",
- "Concurrency limit exceeded for account, please retry later", http.StatusTooManyRequests)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- logOpsStreamError(c, ops, http.StatusOK)
- require.Equal(t, int64(1), OpsErrorLogEnqueuedTotal())
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.NotNil(t, job.entry)
- require.Equal(t, "rate_limit_error", job.entry.ErrorType)
- require.Equal(t, "request", job.entry.ErrorPhase)
- require.True(t, job.entry.IsBusinessLimited)
- require.True(t, job.entry.Stream)
- require.Equal(t, http.StatusOK, job.entry.StatusCode) // wire 状态码保持 200
- require.Equal(t, "P1", job.entry.Severity) // 用 IntendedStatus 429 分级
- require.Equal(t, "test-model", job.entry.Model)
- require.Equal(t, "Concurrency limit exceeded for account, please retry later", job.entry.ErrorMessage)
- }
- func TestLogOpsStreamError_UpstreamFailureCountsTowardsSLA(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 4)
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
- c.Set(opsModelKey, "gpt-5.6-sol")
- service.MarkOpsStreamFailure(
- c,
- "upstream_error",
- service.OpenAIUpstreamHTTP2StreamErrorCode,
- "Upstream HTTP/2 stream failed",
- http.StatusBadGateway,
- )
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- logOpsStreamError(c, ops, http.StatusOK)
- job := <-opsErrorLogQueue
- require.NotNil(t, job.entry)
- require.Equal(t, http.StatusBadGateway, job.entry.StatusCode)
- require.Equal(t, "upstream_error", job.entry.ErrorType)
- require.Equal(t, "upstream", job.entry.ErrorPhase)
- require.Equal(t, "provider", job.entry.ErrorOwner)
- require.False(t, job.entry.IsBusinessLimited)
- require.Contains(t, job.entry.ErrorBody, service.OpenAIUpstreamHTTP2StreamErrorCode)
- }
- // 未标记流内错误时 logOpsStreamError 必须是 no-op(不误记正常的 200 流)。
- func TestLogOpsStreamError_NoopWhenNotMarked(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 4)
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- logOpsStreamError(c, ops, http.StatusOK)
- require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
- }
- // 命中 skip_monitoring=true 透传规则时不落库,与其它采集分支一致。
- func TestLogOpsStreamError_SkipWhenPassthroughSkipMonitoring(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 4)
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway)
- c.Set(service.OpsSkipPassthroughKey, true)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- logOpsStreamError(c, ops, http.StatusOK)
- require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
- }
- func TestShouldSkipFinalOpsFailureUsesOnlyFinalAttemptRule(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
- {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden intermediate", SkipMonitoring: true},
- {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"},
- })
- require.False(t, shouldSkipFinalOpsFailure(c))
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
- {UpstreamStatusCode: http.StatusBadGateway, Message: "visible intermediate"},
- nil,
- {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "hidden final", SkipMonitoring: true},
- })
- require.True(t, shouldSkipFinalOpsFailure(c))
- }
- // MarkOpsStreamError 采用「首个标记生效」:后续的通用兜底帧不得覆盖根因错误。
- func TestMarkOpsStreamError_FirstWins(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- service.MarkOpsStreamError(c, "rate_limit_error", "Concurrency limit exceeded for account", http.StatusTooManyRequests)
- service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway)
- se, ok := service.GetOpsStreamError(c)
- require.True(t, ok)
- require.Equal(t, "rate_limit_error", se.ErrType)
- require.Equal(t, "Concurrency limit exceeded for account", se.Message)
- require.Equal(t, http.StatusTooManyRequests, se.IntendedStatus)
- }
- func TestLogOpsStreamError_RecordsOneFailurePerWebSocketTurn(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 4)
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
- service.SetOpenAIClientTransport(c, service.OpenAIClientTransportWS)
- service.BeginOpsStreamTurn(c, 1)
- service.MarkOpsStreamFailure(c, "rate_limit_error", "rate_limit_exceeded", "turn one failed", http.StatusTooManyRequests)
- service.MarkOpsStreamError(c, "upstream_error", "generic duplicate for turn one", http.StatusBadGateway)
- service.BeginOpsStreamTurn(c, 2)
- service.MarkOpsStreamFailure(c, "permission_error", "permission_denied", "turn two failed", http.StatusForbidden)
- streamErrors := service.GetOpsStreamErrors(c)
- require.Len(t, streamErrors, 2)
- require.Equal(t, 1, streamErrors[0].Turn)
- require.Equal(t, 2, streamErrors[1].Turn)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- logOpsStreamError(c, ops, http.StatusSwitchingProtocols)
- require.Equal(t, int64(2), OpsErrorLogQueueLength())
- first := <-opsErrorLogQueue
- second := <-opsErrorLogQueue
- require.Equal(t, "turn one failed", first.entry.ErrorMessage)
- require.Equal(t, http.StatusTooManyRequests, first.entry.StatusCode)
- require.Equal(t, "turn two failed", second.entry.ErrorMessage)
- require.Equal(t, http.StatusForbidden, second.entry.StatusCode)
- }
- func TestIsKnownOpsErrorType(t *testing.T) {
- known := []string{
- "invalid_request_error",
- "authentication_error",
- "rate_limit_error",
- "billing_error",
- "subscription_error",
- "upstream_error",
- "overloaded_error",
- "api_error",
- "not_found_error",
- "forbidden_error",
- }
- for _, k := range known {
- require.True(t, isKnownOpsErrorType(k), "expected known: %s", k)
- }
- unknown := []string{"<nil>", "null", "", "random_error", "some_new_type", "<nil>\u003e"}
- for _, u := range unknown {
- require.False(t, isKnownOpsErrorType(u), "expected unknown: %q", u)
- }
- }
- func TestNormalizeOpsErrorType(t *testing.T) {
- tests := []struct {
- name string
- errType string
- code string
- want string
- }{
- // Known types pass through.
- {"known invalid_request_error", "invalid_request_error", "", "invalid_request_error"},
- {"known rate_limit_error", "rate_limit_error", "", "rate_limit_error"},
- {"known upstream_error", "upstream_error", "", "upstream_error"},
- // Unknown/garbage types are rejected and fall through to code-based or default.
- {"nil literal from upstream", "<nil>", "", "api_error"},
- {"null string", "null", "", "api_error"},
- {"random string", "something_weird", "", "api_error"},
- // Unknown type but known code still maps correctly.
- {"nil with INSUFFICIENT_BALANCE code", "<nil>", "INSUFFICIENT_BALANCE", "billing_error"},
- {"nil with USAGE_LIMIT_EXCEEDED code", "<nil>", "USAGE_LIMIT_EXCEEDED", "subscription_error"},
- // Empty type falls through to code-based mapping.
- {"empty type with balance code", "", "INSUFFICIENT_BALANCE", "billing_error"},
- {"empty type with subscription code", "", "SUBSCRIPTION_NOT_FOUND", "subscription_error"},
- {"empty type no code", "", "", "api_error"},
- // Known type overrides conflicting code-based mapping.
- {"known type overrides conflicting code", "rate_limit_error", "INSUFFICIENT_BALANCE", "rate_limit_error"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := normalizeOpsErrorType(tt.errType, tt.code)
- require.Equal(t, tt.want, got)
- })
- }
- }
- func TestClassifyOpsNoAvailableAccountsExcludedFromSLA(t *testing.T) {
- const message = "No available accounts"
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- markOpsRoutingCapacityLimited(c)
- errType := normalizeOpsErrorType("api_error", "")
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, message, "", http.StatusServiceUnavailable)
- require.Equal(t, "api_error", errType)
- require.Equal(t, "routing", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "platform", errorOwner)
- require.Equal(t, "gateway", errorSource)
- }
- func TestClassifyOpsRoutingCapacityMarkerExcludesMaskedSelectionFailureFromSLA(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- markOpsRoutingCapacityLimited(c)
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
- c,
- "api_error",
- "Service temporarily unavailable",
- "",
- http.StatusServiceUnavailable,
- )
- require.Equal(t, "routing", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "platform", errorOwner)
- require.Equal(t, "gateway", errorSource)
- }
- func TestClassifyOpsLocalModelConfigurationRejection(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
- c,
- "model_not_found",
- "Model \"gpt-missing\" is not supported by any configured account in this group",
- "",
- http.StatusNotFound,
- )
- require.Equal(t, "routing", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "platform", errorOwner)
- require.Equal(t, "gateway", errorSource)
- }
- func TestClassifyOpsLocalModelConfigurationOverridesStaleUpstreamMarkers(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
- c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
- Stage: string(service.GatewayFailureStageAccountAuth),
- UpstreamStatusCode: http.StatusUnauthorized,
- }})
- phase, limited, owner, source := classifyOpsErrorLog(c, "model_not_found", "unsupported configured model", "", http.StatusNotFound)
- require.Equal(t, "routing", phase)
- require.True(t, limited)
- require.Equal(t, "platform", owner)
- require.Equal(t, "gateway", source)
- }
- func TestClassifyOpsLocalModelConfigurationRequiresMarkerAndReason(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- c.Set(service.OpsClientBusinessLimitedReasonKey, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
- c.Set(service.OpsUpstreamStatusCodeKey, http.StatusBadGateway)
- phase, limited, owner, source := classifyOpsErrorLog(c, "upstream_error", "provider failed", "", http.StatusBadGateway)
- require.Equal(t, "upstream", phase)
- require.False(t, limited)
- require.Equal(t, "provider", owner)
- require.Equal(t, "upstream_http", source)
- }
- func TestOpsErrorLoggerMiddleware_LocalModelConfigurationFields(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 1)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/chat/completions", func(c *gin.Context) {
- service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
- c.Set(opsAccountIDKey, int64(99))
- c.Set(opsUpstreamModelKey, "stale-upstream-model")
- setActualUpstreamEndpoint(c, "/v1/chat/completions")
- c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
- c.Set(service.OpsUpstreamErrorMessageKey, "stale upstream error")
- c.Set(service.OpsUpstreamErrorDetailKey, "stale upstream detail")
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
- Stage: string(service.GatewayFailureStageAccountAuth),
- UpstreamStatusCode: http.StatusUnauthorized,
- Message: "stale auth failure",
- }})
- c.JSON(http.StatusNotFound, gin.H{
- "error": gin.H{
- "type": "model_not_found",
- "message": "Model \"gpt-missing\" is not supported by any configured account in this group",
- },
- })
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusNotFound, w.Code)
- require.JSONEq(t, `{"error":{"type":"model_not_found","message":"Model \"gpt-missing\" is not supported by any configured account in this group"}}`, w.Body.String())
- job := <-opsErrorLogQueue
- require.Equal(t, http.StatusNotFound, job.entry.StatusCode)
- require.Equal(t, "routing", job.entry.ErrorPhase)
- require.True(t, job.entry.IsBusinessLimited)
- require.Equal(t, "platform", job.entry.ErrorOwner)
- require.Equal(t, "gateway", job.entry.ErrorSource)
- require.Nil(t, job.entry.AccountID)
- require.Nil(t, job.entry.UpstreamStatusCode)
- require.Nil(t, job.entry.UpstreamErrors)
- require.Nil(t, job.entry.UpstreamErrorMessage)
- require.Nil(t, job.entry.UpstreamErrorDetail)
- require.Empty(t, job.entry.UpstreamModel)
- require.Empty(t, job.entry.UpstreamEndpoint)
- }
- func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) {
- tests := []struct {
- name string
- errType string
- message string
- code string
- status int
- }{
- {
- name: "standard invalid API key",
- errType: "api_error",
- message: "Invalid API key",
- code: "INVALID_API_KEY",
- status: http.StatusUnauthorized,
- },
- {
- name: "standard missing API key",
- errType: "api_error",
- message: "API key is required in Authorization header (Bearer scheme), x-api-key header, or x-goog-api-key header",
- code: "API_KEY_REQUIRED",
- status: http.StatusUnauthorized,
- },
- {
- name: "expired local API key",
- errType: "api_error",
- message: "API key 已过期",
- code: "API_KEY_EXPIRED",
- status: http.StatusForbidden,
- },
- {
- name: "disabled local API key",
- errType: "api_error",
- message: "API key is disabled",
- code: "API_KEY_DISABLED",
- status: http.StatusUnauthorized,
- },
- {
- name: "local API key user missing",
- errType: "api_error",
- message: "User associated with API key not found",
- code: "USER_NOT_FOUND",
- status: http.StatusUnauthorized,
- },
- {
- name: "inactive local API key user",
- errType: "api_error",
- message: "User account is not active",
- code: "USER_INACTIVE",
- status: http.StatusUnauthorized,
- },
- {
- name: "deleted local API key group",
- errType: "api_error",
- message: "API Key 所属分组已删除",
- code: "GROUP_DELETED",
- status: http.StatusForbidden,
- },
- {
- name: "disabled local API key group",
- errType: "api_error",
- message: "API Key 所属分组已停用",
- code: "GROUP_DISABLED",
- status: http.StatusForbidden,
- },
- {
- name: "google deleted API key group message without semantic code",
- errType: "api_error",
- message: "API Key 所属分组已删除",
- code: "403",
- status: http.StatusForbidden,
- },
- {
- name: "anthropic unassigned API key group",
- errType: "permission_error",
- message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.",
- code: "",
- status: http.StatusForbidden,
- },
- {
- name: "google invalid API key",
- errType: "api_error",
- message: "Invalid API key",
- code: "401",
- status: http.StatusUnauthorized,
- },
- {
- name: "google missing API key",
- errType: "api_error",
- message: "API key is required",
- code: "401",
- status: http.StatusUnauthorized,
- },
- {
- name: "google disabled API key",
- errType: "api_error",
- message: "API key is disabled",
- code: "401",
- status: http.StatusUnauthorized,
- },
- {
- name: "google local API key user missing",
- errType: "api_error",
- message: "User associated with API key not found",
- code: "401",
- status: http.StatusUnauthorized,
- },
- {
- name: "google inactive local API key user",
- errType: "api_error",
- message: "User account is not active",
- code: "401",
- status: http.StatusUnauthorized,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- errType := normalizeOpsErrorType(tt.errType, tt.code)
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, tt.message, tt.code, tt.status)
- wantErrType := "api_error"
- if tt.errType == "permission_error" {
- wantErrType = "permission_error"
- }
- require.Equal(t, wantErrType, errType)
- require.Equal(t, "auth", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "client", errorOwner)
- require.Equal(t, "client_request", errorSource)
- })
- }
- }
- func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
- tests := []struct {
- name string
- errType string
- message string
- code string
- status int
- wantErrType string
- wantPhase string
- }{
- {
- name: "standard API key quota exhausted",
- errType: "api_error",
- message: "API key 额度已用完",
- code: "API_KEY_QUOTA_EXHAUSTED",
- status: http.StatusTooManyRequests,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "standard query API key deprecated",
- errType: "api_error",
- message: "API key in query parameter is deprecated. Please use Authorization header instead.",
- code: "api_key_in_query_deprecated",
- status: http.StatusBadRequest,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "google query API key deprecated",
- errType: "api_error",
- message: "Query parameter api_key is deprecated. Use Authorization header or key instead.",
- code: "400",
- status: http.StatusBadRequest,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "google no active subscription",
- errType: "api_error",
- message: "No active subscription found for this group",
- code: "403",
- status: http.StatusForbidden,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "gateway subscription invalid cache recheck",
- errType: "billing_error",
- message: "subscription is invalid or expired",
- code: "billing_error",
- status: http.StatusForbidden,
- wantErrType: "billing_error",
- wantPhase: "request",
- },
- {
- name: "google insufficient account balance",
- errType: "api_error",
- message: "Insufficient account balance",
- code: "403",
- status: http.StatusForbidden,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "gateway billing cache insufficient balance",
- errType: "billing_error",
- message: "insufficient balance",
- code: "",
- status: http.StatusForbidden,
- wantErrType: "billing_error",
- wantPhase: "request",
- },
- {
- name: "gemini group platform mismatch",
- errType: "api_error",
- message: "API key group platform is not gemini",
- code: "400",
- status: http.StatusBadRequest,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "gateway API key 5h rate limit",
- errType: "api_error",
- message: "api key 5小时限额已用完",
- code: "rate_limit_exceeded",
- status: http.StatusTooManyRequests,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "gateway group RPM limit",
- errType: "api_error",
- message: "group requests-per-minute limit exceeded",
- code: "rate_limit_exceeded",
- status: http.StatusTooManyRequests,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "google subscription daily limit",
- errType: "api_error",
- message: "daily usage limit exceeded",
- code: "429",
- status: http.StatusTooManyRequests,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "user platform daily quota exhausted",
- errType: "api_error",
- message: "Daily usage quota exhausted for this platform.",
- code: "rate_limit_exceeded",
- status: http.StatusTooManyRequests,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "local pending queue limit",
- errType: "rate_limit_error",
- message: "Too many pending requests, please retry later",
- code: "",
- status: http.StatusTooManyRequests,
- wantErrType: "rate_limit_error",
- wantPhase: "request",
- },
- {
- name: "local concurrency limit",
- errType: "rate_limit_error",
- message: "Concurrency limit exceeded for user, please retry later",
- code: "",
- status: http.StatusTooManyRequests,
- wantErrType: "rate_limit_error",
- wantPhase: "request",
- },
- {
- name: "group claude code only feature gate",
- errType: "permission_error",
- message: "This group is restricted to Claude Code clients (/v1/messages only)",
- code: "",
- status: http.StatusForbidden,
- wantErrType: "permission_error",
- wantPhase: "request",
- },
- {
- name: "group image generation feature gate",
- errType: "permission_error",
- message: "Image generation is not enabled for this group",
- code: "",
- status: http.StatusForbidden,
- wantErrType: "permission_error",
- wantPhase: "request",
- },
- {
- name: "group reasoning effort over limit deny",
- errType: "permission_error",
- message: `reasoning effort "high" exceeds this group's limit of "low"`,
- code: "",
- status: http.StatusForbidden,
- wantErrType: "permission_error",
- wantPhase: "request",
- },
- {
- name: "route token counting platform unsupported",
- errType: "not_found_error",
- message: "Token counting is not supported for this platform",
- code: "",
- status: http.StatusNotFound,
- wantErrType: "not_found_error",
- wantPhase: "request",
- },
- {
- name: "route images API platform unsupported",
- errType: "not_found_error",
- message: "Images API is not supported for this platform",
- code: "",
- status: http.StatusNotFound,
- wantErrType: "not_found_error",
- wantPhase: "request",
- },
- {
- name: "antigravity model whitelist feature gate",
- errType: "permission_error",
- message: "model claude-3-5-sonnet not in whitelist",
- code: "",
- status: http.StatusForbidden,
- wantErrType: "permission_error",
- wantPhase: "request",
- },
- {
- name: "google antigravity model whitelist feature gate",
- errType: "api_error",
- message: "model gemini-2.5-pro not in whitelist",
- code: "403",
- status: http.StatusForbidden,
- wantErrType: "api_error",
- wantPhase: "request",
- },
- {
- name: "claude beta policy block",
- errType: "invalid_request_error",
- message: "beta feature interleaved-thinking-2025-05-14 is not allowed",
- code: "",
- status: http.StatusBadRequest,
- wantErrType: "invalid_request_error",
- wantPhase: "request",
- },
- {
- name: "openai fast policy block",
- errType: "permission_error",
- message: "openai service_tier=priority is not allowed for model gpt-5.5",
- code: "",
- status: http.StatusForbidden,
- wantErrType: "permission_error",
- wantPhase: "request",
- },
- {
- name: "codex official client policy block",
- errType: "forbidden_error",
- message: "This account only allows Codex official clients",
- code: "",
- status: http.StatusForbidden,
- wantErrType: "forbidden_error",
- wantPhase: "request",
- },
- {
- name: "openai wsv1 unsupported feature gate",
- errType: "invalid_request_error",
- message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
- code: "",
- status: http.StatusBadRequest,
- wantErrType: "invalid_request_error",
- wantPhase: "request",
- },
- {
- name: "openai passthrough instructions policy block",
- errType: "forbidden_error",
- message: "OpenAI codex passthrough requires a non-empty instructions field",
- code: "",
- status: http.StatusForbidden,
- wantErrType: "forbidden_error",
- wantPhase: "request",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- errType := normalizeOpsErrorType(tt.errType, tt.code)
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, tt.message, tt.code, tt.status)
- require.Equal(t, tt.wantErrType, errType)
- require.Equal(t, tt.wantPhase, phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "client", errorOwner)
- require.Equal(t, "client_request", errorSource)
- })
- }
- }
- func TestClassifyOpsIPRestrictionAccessDeniedExcludedFromSLA(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction)
- errType := normalizeOpsErrorType("api_error", "ACCESS_DENIED")
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "Access denied", "ACCESS_DENIED", http.StatusForbidden)
- require.Equal(t, "api_error", errType)
- require.Equal(t, "auth", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "client", errorOwner)
- require.Equal(t, "client_request", errorSource)
- }
- func TestClassifyOpsClientBusinessLimitedMarkerExcludesCustomPolicyDenialFromSLA(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
- errType := normalizeOpsErrorType("invalid_request_error", "")
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "custom admin policy message", "", http.StatusBadRequest)
- require.Equal(t, "invalid_request_error", errType)
- require.Equal(t, "auth", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "client", errorOwner)
- require.Equal(t, "client_request", errorSource)
- }
- func TestClassifyOpsOtherErrorsStillCountForSLA(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- errType := normalizeOpsErrorType("api_error", "INTERNAL_ERROR")
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "Failed to validate API key", "INTERNAL_ERROR", http.StatusInternalServerError)
- require.Equal(t, "api_error", errType)
- require.Equal(t, "internal", phase)
- require.False(t, isBusinessLimited)
- require.Equal(t, "platform", errorOwner)
- require.Equal(t, "gateway", errorSource)
- }
- func TestClassifyOpsUnsupportedModelExcludedFromSLA(t *testing.T) {
- tests := []string{
- "No available accounts: no available accounts supporting model: made-up-model",
- "No available accounts: no available OpenAI accounts supporting model: made-up-model",
- "No available Gemini accounts: no available Gemini accounts supporting model: made-up-model",
- "No available accounts: no available accounts supporting model: made-up-model (channel pricing restriction)",
- }
- for _, message := range tests {
- t.Run(message, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- markOpsRoutingCapacityLimited(c)
- errType := normalizeOpsErrorType("api_error", "")
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, message, "", http.StatusServiceUnavailable)
- require.Equal(t, "api_error", errType)
- require.Equal(t, "routing", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "platform", errorOwner)
- require.Equal(t, "gateway", errorSource)
- })
- }
- }
- func TestClassifyOpsUnmarkedNoAvailableTextStillCountsForSLA(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
- c,
- "api_error",
- "No available accounts",
- "",
- http.StatusServiceUnavailable,
- )
- require.Equal(t, "routing", phase)
- require.False(t, isBusinessLimited)
- require.Equal(t, "platform", errorOwner)
- require.Equal(t, "gateway", errorSource)
- }
- func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
- tests := []struct {
- name string
- message string
- code string
- status int
- }{
- {
- name: "invalid API key",
- message: "Invalid API key",
- code: "401",
- status: http.StatusUnauthorized,
- },
- {
- name: "disabled API key",
- message: "API key is disabled",
- code: "API_KEY_DISABLED",
- status: http.StatusUnauthorized,
- },
- {
- name: "gemini group platform mismatch",
- message: "API key group platform is not gemini",
- code: "400",
- status: http.StatusBadRequest,
- },
- {
- name: "provider balance error",
- message: "Insufficient account balance",
- code: "INSUFFICIENT_BALANCE",
- status: http.StatusForbidden,
- },
- {
- name: "provider subscription error",
- message: "No active subscription found for this group",
- code: "SUBSCRIPTION_NOT_FOUND",
- status: http.StatusForbidden,
- },
- {
- name: "provider quota error",
- message: "api key 额度已用完",
- code: "API_KEY_QUOTA_EXHAUSTED",
- status: http.StatusTooManyRequests,
- },
- {
- name: "provider deleted group shaped error",
- message: "API Key 所属分组已删除",
- code: "GROUP_DELETED",
- status: http.StatusForbidden,
- },
- {
- name: "provider unassigned group shaped error",
- message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.",
- code: "403",
- status: http.StatusForbidden,
- },
- {
- name: "provider local quota shaped error",
- message: "Daily usage quota exhausted for this platform.",
- code: "rate_limit_exceeded",
- status: http.StatusTooManyRequests,
- },
- {
- name: "provider feature gate shaped error",
- message: "Image generation is not enabled for this group",
- code: "403",
- status: http.StatusForbidden,
- },
- {
- name: "provider reasoning effort over limit shaped error",
- message: `reasoning effort "high" exceeds this group's limit of "low"`,
- code: "403",
- status: http.StatusForbidden,
- },
- {
- name: "provider token counting unsupported shaped error",
- message: "Token counting is not supported for this platform",
- code: "404",
- status: http.StatusNotFound,
- },
- {
- name: "provider image API unsupported shaped error",
- message: "Images API is not supported for this platform",
- code: "404",
- status: http.StatusNotFound,
- },
- {
- name: "provider antigravity whitelist shaped error",
- message: "model claude-3-5-sonnet not in whitelist",
- code: "403",
- status: http.StatusForbidden,
- },
- {
- name: "provider beta policy shaped error",
- message: "beta feature interleaved-thinking-2025-05-14 is not allowed",
- code: "400",
- status: http.StatusBadRequest,
- },
- {
- name: "provider openai fast policy shaped error",
- message: "openai service_tier=priority is not allowed for model gpt-5.5",
- code: "403",
- status: http.StatusForbidden,
- },
- {
- name: "provider codex client policy shaped error",
- message: "This account only allows Codex official clients",
- code: "403",
- status: http.StatusForbidden,
- },
- {
- name: "provider wsv1 unsupported shaped error",
- message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
- code: "400",
- status: http.StatusBadRequest,
- },
- {
- name: "provider passthrough instructions shaped error",
- message: "OpenAI codex passthrough requires a non-empty instructions field",
- code: "403",
- status: http.StatusForbidden,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- service.SetOpsUpstreamError(c, tt.status, tt.message, "")
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
- c,
- "api_error",
- tt.message,
- tt.code,
- tt.status,
- )
- require.Equal(t, "upstream", phase)
- require.False(t, isBusinessLimited)
- require.Equal(t, "provider", errorOwner)
- require.Equal(t, "upstream_http", errorSource)
- })
- }
- }
- func TestClassifyOpsUpstreamNoAvailableTextStillCountsForSLA(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- service.SetOpsUpstreamError(c, http.StatusServiceUnavailable, "No available accounts", "")
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
- c,
- "api_error",
- "No available accounts",
- "",
- http.StatusServiceUnavailable,
- )
- require.Equal(t, "upstream", phase)
- require.False(t, isBusinessLimited)
- require.Equal(t, "provider", errorOwner)
- require.Equal(t, "upstream_http", errorSource)
- }
- func TestParseOpsErrorResponsePreservesNestedStringCode(t *testing.T) {
- parsed := parseOpsErrorResponse([]byte(`{"error":{"type":"permission_error","code":"GROUP_DELETED","message":"API Key 所属分组已删除"}}`))
- require.Equal(t, "permission_error", parsed.ErrorType)
- require.Equal(t, "GROUP_DELETED", parsed.Code)
- require.Equal(t, "API Key 所属分组已删除", parsed.Message)
- }
- func TestParseOpsErrorResponsePreservesStructuredTopLevelSemantics(t *testing.T) {
- tests := []struct {
- name string
- body string
- wantType string
- wantCode string
- wantMsg string
- }{
- {
- name: "model not found",
- body: `{"type":"model_not_found","code":404,"message":"model unavailable"}`,
- wantType: "model_not_found",
- wantCode: "404",
- wantMsg: "model unavailable",
- },
- {
- name: "string error",
- body: `{"type":"service_unavailable","code":"temporarily_unavailable","error":"capacity exhausted"}`,
- wantType: "service_unavailable",
- wantCode: "temporarily_unavailable",
- wantMsg: "capacity exhausted",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- parsed := parseOpsErrorResponse([]byte(tt.body))
- require.Equal(t, tt.wantType, normalizeOpsErrorType(parsed.ErrorType, parsed.Code))
- require.Equal(t, tt.wantCode, parsed.Code)
- require.Equal(t, tt.wantMsg, parsed.Message)
- })
- }
- }
- func TestApplyOpsUpstreamFieldsUsesLastNonNilAttempt(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- service.SetOpsUpstreamError(c, http.StatusUnauthorized, "stale context", "stale detail")
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
- {UpstreamStatusCode: http.StatusTooManyRequests, Message: "first attempt", Detail: "first detail"},
- nil,
- {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "final attempt", Detail: "final detail"},
- nil,
- })
- entry := &service.OpsInsertErrorLogInput{}
- applyOpsUpstreamFieldsFromContext(c, entry)
- require.NotNil(t, entry.UpstreamStatusCode)
- require.Equal(t, http.StatusServiceUnavailable, *entry.UpstreamStatusCode)
- require.NotNil(t, entry.UpstreamErrorMessage)
- require.Equal(t, "final attempt", *entry.UpstreamErrorMessage)
- require.NotNil(t, entry.UpstreamErrorDetail)
- require.Equal(t, "final detail", *entry.UpstreamErrorDetail)
- require.Len(t, entry.UpstreamErrors, 4)
- }
- func TestApplyOpsUpstreamFieldsFinalStatuslessAttemptClearsStaleContext(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- service.SetOpsUpstreamError(c, http.StatusBadGateway, "stale response", "stale body")
- c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
- {UpstreamStatusCode: http.StatusBadGateway, Message: "first response"},
- {Kind: "request_error", Message: "final transport failure", Detail: "connection reset"},
- })
- entry := &service.OpsInsertErrorLogInput{}
- applyOpsUpstreamFieldsFromContext(c, entry)
- require.Nil(t, entry.UpstreamStatusCode)
- require.NotNil(t, entry.UpstreamErrorMessage)
- require.Equal(t, "final transport failure", *entry.UpstreamErrorMessage)
- require.NotNil(t, entry.UpstreamErrorDetail)
- require.Equal(t, "connection reset", *entry.UpstreamErrorDetail)
- }
- func TestOpsCaptureWriter_ProtocolLevelTerminalFrameDetection(t *testing.T) {
- state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
- chunks := []string{
- "event : response.failed\r\n",
- "data: { \"response\" : { \"error\" : { \"message\" : \"busy\", \"code\" : \"service_unavailable\" } },",
- " \"type\" : \"response.failed\" }\r\n\r\n",
- }
- for _, chunk := range chunks {
- state.captureResponseChunk([]byte(chunk), http.StatusOK)
- }
- parsed := parseOpsErrorResponse(state.buf.Bytes())
- require.True(t, state.sseCapturing)
- require.True(t, parsed.StreamFailure)
- require.Equal(t, "service_unavailable_error", parsed.ErrorType)
- require.Equal(t, "service_unavailable", parsed.Code)
- require.Equal(t, "busy", parsed.Message)
- require.Equal(t, http.StatusServiceUnavailable, inferStreamFailureStatus(nil, parsed))
- }
- func TestParseOpsSSEFailure_TopLevelErrorsAndUnknownStatus(t *testing.T) {
- tests := []struct {
- name string
- body string
- wantType string
- wantStatus int
- }{
- {
- name: "top-level permission",
- body: "event: error\ndata: {\"message\":\"denied\",\"code\":\"permission_denied\",\"type\":\"error\"}\n\n",
- wantType: "permission_error",
- wantStatus: http.StatusForbidden,
- },
- {
- name: "top-level unavailable",
- body: "data: {\"message\":\"busy\",\"type\":\"error\",\"code\":\"service_unavailable\"}\n\n",
- wantType: "service_unavailable_error",
- wantStatus: http.StatusServiceUnavailable,
- },
- {
- name: "unknown terminal",
- body: "event: response.failed\ndata: {\"type\":\"response.failed\",\"error\":{\"code\":\"new_provider_code\",\"message\":\"failed\"}}\n\n",
- wantType: "upstream_error",
- wantStatus: http.StatusBadGateway,
- },
- {
- name: "explicit terminal status",
- body: "event: error\ndata: {\"type\":\"error\",\"status_code\":429,\"code\":\"new_rate_code\",\"message\":\"slow down\"}\n\n",
- wantType: "api_error",
- wantStatus: http.StatusTooManyRequests,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- parsed := parseOpsErrorResponse([]byte(tt.body))
- require.True(t, parsed.StreamFailure)
- require.Equal(t, tt.wantType, parsed.ErrorType)
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- service.SetOpsUpstreamError(c, http.StatusUnauthorized, "old attempt", "")
- require.Equal(t, tt.wantStatus, inferStreamFailureStatus(c, parsed), "terminal status must not inherit an earlier attempt")
- })
- }
- }
- func TestOpsCaptureWriter_OversizedNonTerminalFrameRemainsBounded(t *testing.T) {
- state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
- state.captureResponseChunk([]byte("data: "+strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2)+"\n\n"), http.StatusOK)
- require.Empty(t, state.buf.Bytes())
- require.LessOrEqual(t, cap(state.probe), opsTerminalSSEFrameProbeLimit)
- state.captureResponseChunk([]byte("event: error\ndata: {\"type\":\"error\",\"code\":\"permission_denied\",\"message\":\"denied\"}\n\n"), http.StatusOK)
- require.True(t, state.sseCapturing)
- require.NotEmpty(t, state.buf.Bytes())
- }
- func TestOpsCaptureWriter_TerminalMetadataSurvivesBodyCaptureTruncation(t *testing.T) {
- state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
- frame := "event: response.failed\ndata: {\"padding\":\"" + strings.Repeat("x", opsCaptureWriterLimit) + "\",\"type\":\"response.failed\",\"error\":{\"code\":\"service_unavailable\",\"message\":\"busy\"}}\n\n"
- state.captureResponseChunk([]byte(frame), http.StatusOK)
- state.finalizeResponseCapture()
- require.Len(t, state.buf.Bytes(), opsCaptureWriterLimit)
- require.True(t, parseOpsErrorResponse(state.buf.Bytes()).StreamFailure, "the bounded parser must fail closed from the terminal event line")
- require.True(t, state.terminalFound)
- require.Equal(t, "service_unavailable_error", state.terminalError.ErrorType)
- require.Equal(t, "busy", state.terminalError.Message)
- }
- func TestOpsErrorLoggerMiddleware_LargeTerminalFrameUsesEventFallback(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.Status(http.StatusOK)
- _, _ = c.Writer.WriteString("event: response.failed\n")
- _, _ = c.Writer.WriteString("data: {\"authorization\":\"Bearer must-not-persist\",\"padding\":\"" + strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2) + "\"}")
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, http.StatusOK, recorder.Code)
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, http.StatusBadGateway, job.entry.StatusCode)
- require.Equal(t, "upstream_error", job.entry.ErrorType)
- require.Equal(t, "upstream stream failed", job.entry.ErrorMessage)
- require.NotContains(t, job.entry.ErrorMessage, "must-not-persist")
- require.NotContains(t, job.entry.ErrorBody, "must-not-persist")
- require.Contains(t, job.entry.ErrorBody, `"payload_truncated":true`)
- }
- func TestOpsErrorLoggerMiddleware_DetectsTerminalDataAtEOFWithoutBlankLine(t *testing.T) {
- setupOpsErrorLogTestQueue(t, 2)
- gin.SetMode(gin.TestMode)
- ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router := gin.New()
- router.Use(OpsErrorLoggerMiddleware(ops))
- router.POST("/v1/responses", func(c *gin.Context) {
- c.Status(http.StatusOK)
- _, _ = c.Writer.WriteString(`data: {"message":"denied","code":"permission_denied","type":"error"}`)
- })
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
- require.Equal(t, http.StatusOK, recorder.Code)
- require.Equal(t, int64(1), OpsErrorLogQueueLength())
- job := <-opsErrorLogQueue
- require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
- require.Equal(t, "permission_error", job.entry.ErrorType)
- require.Equal(t, "denied", job.entry.ErrorMessage)
- }
- func TestOpsCaptureWriter_DetectsCROnlySSEFrame(t *testing.T) {
- state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
- state.captureResponseChunk([]byte("data: {\"type\":\"error\",\"code\":\"service_unavailable\",\"message\":\"busy\"}\r\r"), http.StatusOK)
- require.True(t, state.sseCapturing)
- parsed := parseOpsErrorResponse(state.buf.Bytes())
- require.True(t, parsed.StreamFailure)
- require.Equal(t, "service_unavailable_error", parsed.ErrorType)
- }
- func TestSanitizeOpsSSEDataForPersistence_RedactsJSONFields(t *testing.T) {
- body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer secret\",\ndata: \"nested\":{\"api_key\":\"sk-secret\"}}\n\n")
- sanitized := sanitizeOpsSSEDataForPersistence(body)
- require.NotContains(t, sanitized, "Bearer secret")
- require.NotContains(t, sanitized, "sk-secret")
- require.Contains(t, sanitized, `"authorization":"[REDACTED]"`)
- require.Contains(t, sanitized, `"api_key":"[REDACTED]"`)
- }
- func TestSanitizeOpsSSEDataForPersistence_DropsTruncatedJSONFragment(t *testing.T) {
- body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer leaked")
- sanitized := sanitizeOpsSSEDataForPersistence(body)
- require.NotContains(t, sanitized, "Bearer leaked")
- require.Contains(t, sanitized, `data: {"payload_truncated":true}`)
- }
- func BenchmarkOpsCaptureWriterSuccessfulSSEFrames(b *testing.B) {
- frame := []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n")
- state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
- b.ReportAllocs()
- for i := 0; i < b.N; i++ {
- state.captureResponseChunk(frame, http.StatusOK)
- }
- if state.buf.Len() != 0 {
- b.Fatal("successful frames must not be captured")
- }
- }
- func TestSetOpsEndpointContext_SetsContextKeys(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- setOpsEndpointContext(c, "claude-3-5-sonnet-20241022", int16(2)) // stream
- v, ok := c.Get(opsUpstreamModelKey)
- require.True(t, ok)
- vStr, ok := v.(string)
- require.True(t, ok)
- require.Equal(t, "claude-3-5-sonnet-20241022", vStr)
- rt, ok := c.Get(opsRequestTypeKey)
- require.True(t, ok)
- rtVal, ok := rt.(int16)
- require.True(t, ok)
- require.Equal(t, int16(2), rtVal)
- }
- func TestSetOpsEndpointContext_EmptyModelNotStored(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- setOpsEndpointContext(c, "", int16(1))
- _, ok := c.Get(opsUpstreamModelKey)
- require.False(t, ok, "empty upstream model should not be stored")
- rt, ok := c.Get(opsRequestTypeKey)
- require.True(t, ok)
- rtVal, ok := rt.(int16)
- require.True(t, ok)
- require.Equal(t, int16(1), rtVal)
- }
- func TestSetOpsEndpointContext_NilContext(t *testing.T) {
- require.NotPanics(t, func() {
- setOpsEndpointContext(nil, "model", int16(1))
- })
- }
- func TestGetOpsAPIKeyFallsBackToOpsFallbackKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- // 主 key 缺席(鉴权早退场景):返回 nil。
- require.Nil(t, getOpsAPIKey(c))
- // 写入 ops 专用 fallback key 后应能取到,且带齐 user/group。
- groupID := int64(55)
- apiKey := &service.APIKey{
- ID: 100,
- GroupID: &groupID,
- User: &service.User{ID: 7},
- Group: &service.Group{ID: groupID, Platform: service.PlatformAnthropic},
- }
- c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), apiKey)
- got := getOpsAPIKey(c)
- require.NotNil(t, got)
- require.Equal(t, int64(100), got.ID)
- require.NotNil(t, got.User)
- require.Equal(t, int64(7), got.User.ID)
- require.NotNil(t, got.Group)
- require.Equal(t, service.PlatformAnthropic, got.Group.Platform)
- }
- func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- primary := &service.APIKey{ID: 1}
- fallback := &service.APIKey{ID: 2}
- c.Set(string(middleware2.ContextKeyAPIKey), primary)
- c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), fallback)
- got := getOpsAPIKey(c)
- require.NotNil(t, got)
- require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key")
- }
- // 分组模型白名单入口拒绝:业务限流原因复用 local_model_configuration,但
- // 携带 ingress 拒绝原因 model_not_allowed,阶段应保持自然分类(request /
- // client),不落到 routing。
- func TestClassifyOpsIngressModelNotAllowedKeepsRequestPhase(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
- middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
- phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
- c,
- "not_found_error",
- `Model "gpt-4.1" is not available for this group`,
- "model_not_found",
- http.StatusNotFound,
- )
- require.Equal(t, "request", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "client", errorOwner)
- require.Equal(t, "client_request", errorSource)
- }
- // 调度阶段的账号模型映射拒绝(无 ingress 标记)仍归类为 routing。
- func TestClassifyOpsLocalModelConfigurationWithoutIngressMarkStaysRouting(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
- phase, isBusinessLimited, errorOwner, _ := classifyOpsErrorLog(
- c,
- "model_not_found",
- "Model \"gpt-missing\" is not supported by any configured account in this group",
- "",
- http.StatusNotFound,
- )
- require.Equal(t, "routing", phase)
- require.True(t, isBusinessLimited)
- require.Equal(t, "platform", errorOwner)
- }
|