You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@answer.apache.org by li...@apache.org on 2026/07/10 02:19:51 UTC
(answer) 05/25: fix: scope JSON 500 to API routes, skip rewriting already-flushed responses
This is an automated email from the ASF dual-hosted git repository.
LinkinStars pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/answer.git
commit 9234d8024630a41400282f6dab619f716551f815
Author: hhc7 <16...@users.noreply.github.com>
AuthorDate: Tue Jun 2 13:55:35 2026 +0800
fix: scope JSON 500 to API routes, skip rewriting already-flushed responses
---
internal/base/middleware/recovery.go | 26 +++++++++++--
internal/base/middleware/recovery_test.go | 63 +++++++++++++++++++++++++++----
internal/base/server/http.go | 5 ++-
3 files changed, 82 insertions(+), 12 deletions(-)
diff --git a/internal/base/middleware/recovery.go b/internal/base/middleware/recovery.go
index 7e844f78..02b1cbde 100644
--- a/internal/base/middleware/recovery.go
+++ b/internal/base/middleware/recovery.go
@@ -22,6 +22,7 @@ package middleware
import (
"net/http"
"runtime/debug"
+ "strings"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
@@ -29,14 +30,31 @@ import (
"github.com/segmentfault/pacman/log"
)
-func Recovery() gin.HandlerFunc {
+func Recovery(apiPrefixes ...string) gin.HandlerFunc {
return func(ctx *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Errorf("panic recovered: %v\n%s", err, debug.Stack())
- ctx.AbortWithStatusJSON(http.StatusInternalServerError,
- handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError).TrMsg(handler.GetLangByCtx(ctx)),
- )
+
+ // Headers/body already flushed (SSE or any streamed response).
+ // We can no longer rewrite the response cleanly; just stop the chain.
+ if ctx.Writer.Written() {
+ ctx.Abort()
+ return
+ }
+
+ path := ctx.Request.URL.Path
+ for _, p := range apiPrefixes {
+ if strings.HasPrefix(path, p) {
+ ctx.AbortWithStatusJSON(http.StatusInternalServerError,
+ handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError).
+ TrMsg(handler.GetLangByCtx(ctx)),
+ )
+ return
+ }
+ }
+
+ ctx.AbortWithStatus(http.StatusInternalServerError)
}
}()
ctx.Next()
diff --git a/internal/base/middleware/recovery_test.go b/internal/base/middleware/recovery_test.go
index c01719fc..58dbfdb0 100644
--- a/internal/base/middleware/recovery_test.go
+++ b/internal/base/middleware/recovery_test.go
@@ -28,16 +28,17 @@ import (
"github.com/gin-gonic/gin"
)
-func TestRecovery_Panic(t *testing.T) {
+// Panic on an API path returns the project's unified JSON 500.
+func TestRecovery_APIPathPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
- r.Use(Recovery())
- r.GET("/panic", func(ctx *gin.Context) {
+ r.Use(Recovery("/api"))
+ r.GET("/api/panic", func(ctx *gin.Context) {
panic("test panic")
})
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/panic", nil)
+ req, _ := http.NewRequest(http.MethodGet, "/api/panic", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
@@ -53,16 +54,64 @@ func TestRecovery_Panic(t *testing.T) {
}
}
+// Panic on a non-API path returns a bare 500 with no body, so the browser can
+// render its own error page instead of showing raw JSON.
+func TestRecovery_NonAPIPathPanic(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(Recovery("/api"))
+ r.GET("/page", func(ctx *gin.Context) {
+ panic("test panic")
+ })
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest(http.MethodGet, "/page", nil)
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusInternalServerError {
+ t.Errorf("expected 500, got %d", w.Code)
+ }
+ if w.Body.Len() != 0 {
+ t.Errorf("expected empty body for non-API path, got: %q", w.Body.String())
+ }
+}
+
+// Panic after the response has already started writing (SSE / streamed
+// responses). The middleware must not touch the response — status and body
+// already on the wire stay untouched, no JSON gets appended.
+func TestRecovery_PanicAfterResponseStarted(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(Recovery("/api"))
+ r.GET("/api/stream", func(ctx *gin.Context) {
+ ctx.Writer.WriteHeader(http.StatusOK)
+ _, _ = ctx.Writer.Write([]byte("partial data"))
+ panic("test panic after write")
+ })
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest(http.MethodGet, "/api/stream", nil)
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("expected status to remain 200 (already flushed), got %d", w.Code)
+ }
+ if w.Body.String() != "partial data" {
+ t.Errorf("expected body to remain 'partial data' (no error JSON appended), got: %q", w.Body.String())
+ }
+}
+
+// Normal requests pass through unaffected.
func TestRecovery_NoPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
- r.Use(Recovery())
- r.GET("/ok", func(ctx *gin.Context) {
+ r.Use(Recovery("/api"))
+ r.GET("/api/ok", func(ctx *gin.Context) {
ctx.String(http.StatusOK, "ok")
})
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/ok", nil)
+ req, _ := http.NewRequest(http.MethodGet, "/api/ok", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
diff --git a/internal/base/server/http.go b/internal/base/server/http.go
index 1e8204d3..8db55744 100644
--- a/internal/base/server/http.go
+++ b/internal/base/server/http.go
@@ -52,7 +52,10 @@ func NewHTTPServer(debug bool,
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
- r.Use(middleware.Recovery())
+ r.Use(middleware.Recovery(
+ uiConf.APIBaseURL+"/answer/api/v1",
+ uiConf.APIBaseURL+"/answer/admin/api",
+ ))
r.Use(func(ctx *gin.Context) {
if strings.Contains(ctx.Request.URL.Path, "/chat/completions") {
return