From 31b1ed2c965abbe0e79c3b585557f301e0902de4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 24 Aug 2026 16:02:31 +0800 Subject: [PATCH] feat(gateway): collapse multi-tier failure details into short client message Clients saw the full per-tier chain error including upstream HTML WAF pages and JSON quota payloads. The response now carries one capped one-line reason per tier (quota/cooling skips preserved); full detail remains in rec.Err / stats API and is logged server-side. --- internal/gateway/chat.go | 45 ++++++++++++++++++++++++++++---- internal/gateway/gateway_test.go | 2 +- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index b2e447e..6c97490 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "strings" "sync/atomic" @@ -523,6 +524,40 @@ func upstreamErrStatus(err error) int { return http.StatusBadGateway } +// clientUpstreamErr collapses upstream failure details into a short message +// for the client: per-tier bodies (WAF HTML pages, quota payloads, ...) stay +// in rec.Err / the stats API and the server log instead of the response. +// Per-tier one-line reasons are kept (quota/cooling skips carry no error body +// and are the actionable part); each is capped so HTML dumps can't leak. +func clientUpstreamErr(err error) string { + log.Printf("[gateway] upstream failure surfaced to client: %v", err) + var ce *scheduler.ChainErr + if errors.As(err, &ce) { + parts := make([]string, 0, len(ce.Tiers)+len(ce.Skipped)) + for _, t := range ce.Tiers { + parts = append(parts, oneLine(fmt.Sprintf("%s/%s: %v", t.Source, t.Model, t.Err), 80)) + } + for _, sk := range ce.Skipped { + parts = append(parts, oneLine(sk, 80)) + } + msg := strings.Join(parts, "; ") + if len(msg) > 300 { + msg = msg[:300] + "..." + } + return fmt.Sprintf("all %d auto providers failed: %s", len(parts), msg) + } + return oneLine(err.Error(), 160) +} + +// oneLine flattens an error string to a single line capped at n chars. +func oneLine(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) > n { + s = s[:n] + "..." + } + return s +} + func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) { rec.LatMs = 0 t0 := time.Now() @@ -533,7 +568,7 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [ rec.Status = upstreamErrStatus(err) rec.Err = err.Error() g.writeRec(rec) - writeError(w, rec.Status, "upstream_error", err.Error()) + writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err)) return } rec.OK = true @@ -620,7 +655,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ rec.OK = false rec.Status = upstreamErrStatus(err) rec.Err = err.Error() - writeError(w, rec.Status, "upstream_error", err.Error()) + writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err)) return } if usedModel != "" { @@ -741,7 +776,7 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, cha rec.Model = ce.Tiers[0].Model } g.writeRec(rec) - writeError(w, rec.Status, "upstream_error", err.Error()) + writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err)) return } rec.OK = true @@ -799,7 +834,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha rec.Source = ce.Tiers[0].Source rec.Model = ce.Tiers[0].Model } - writeError(w, rec.Status, "upstream_error", err.Error()) + writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err)) return } if usedModel != "" { @@ -946,7 +981,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { rec.Status = upstreamErrStatus(err) rec.Err = err.Error() g.writeRec(rec) - writeError(w, rec.Status, "upstream_error", err.Error()) + writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err)) return } if usedSrc != "" { diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index f8e9ba1..b2e161e 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -139,7 +139,7 @@ func TestChatAutoChain503Summary(t *testing.T) { if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } - if !strings.Contains(rr.Body.String(), "all auto tiers failed") || + if !strings.Contains(rr.Body.String(), "auto providers failed") || !strings.Contains(rr.Body.String(), "a/a-m") || !strings.Contains(rr.Body.String(), "b/b-m") { t.Fatalf("503 must summarize every tier, body=%s", rr.Body.String())