Go / Gin

Gin Panic Recovery Middleware Returning Empty 500 Response

Gin's built-in Recovery() middleware correctly prevents a panic from crashing the entire server, but its default behavior returns a 500 status with no response body at all — which is safe by default (avoiding leaking internal error details to clients) but often not what you actually want for debugging or for giving API clients any useful information.

The Problem

When a handler panics, the server stays up (which is the point of the recovery middleware), but the client receives:

HTTP/1.1 500 Internal Server Error
Content-Length: 0

No error message, no body, nothing to indicate what went wrong — which is fine for production security, but makes debugging during development frustrating, and doesn't give API clients any structured error to handle gracefully.

Why It Happens

Gin's default gin.Recovery() middleware is intentionally minimal — it logs the panic and stack trace to the server's own logs, but deliberately doesn't include panic details in the HTTP response itself, since exposing raw internal error messages (including stack traces) to API clients is a real security concern in production. This safe-by-default behavior means:

  • Using the plain gin.Recovery() without any customization gives you exactly this behavior — safe, but unhelpful for anyone consuming the API or debugging locally
  • Custom recovery logic that doesn't explicitly write a response body before returning also produces an empty body, even if the middleware itself does log something useful server-side
  • Confusion between what should be logged (detailed, for developers) versus what should be returned in the HTTP response (safe, minimal, for clients) leads to either too little information anywhere, or accidentally too much information leaked to clients

The Fix

Replace the default recovery middleware with a custom one that logs full details server-side while returning a clean, safe, structured response to the client:

func customRecovery() gin.HandlerFunc {
    return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
        if err, ok := recovered.(string); ok {
            log.Printf("panic recovered: %s\n%s", err, debug.Stack())
        } else {
            log.Printf("panic recovered: %v\n%s", recovered, debug.Stack())
        }
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": "internal server error",
        })
        c.Abort()
    })
}

Register this in place of the default:

router := gin.New()
router.Use(customRecovery())
// instead of: router.Use(gin.Recovery())

This gives you the full panic message and stack trace in your server logs — exactly what you need for debugging — while the client only ever sees a clean, generic error message without any internal implementation details.

For local development specifically, where seeing the actual error is genuinely useful and there's no real security concern about who's consuming the API, conditionally include more detail based on environment:

c.JSON(http.StatusInternalServerError, gin.H{
    "error": func() string {
        if os.Getenv("APP_ENV") == "development" {
            return fmt.Sprintf("%v", recovered)
        }
        return "internal server error"
    }(),
})

Never ship this development-detail branch active in production — gate it explicitly and double-check the environment variable is correctly set in your actual production deployment, since accidentally leaving verbose error details enabled in production is a real information disclosure risk.

If you want structured, machine-readable error responses (useful for frontend clients to handle errors programmatically rather than just displaying raw text), define a consistent error response shape used across both panic recovery and regular error handling elsewhere in your API:

type ErrorResponse struct {
    Error   string `json:"error"`
    Code    string `json:"code"`
}

c.JSON(http.StatusInternalServerError, ErrorResponse{
    Error: "internal server error",
    Code:  "INTERNAL_ERROR",
})

Still Not Working?

If your custom recovery middleware is in place but responses are still empty, confirm it's actually registered before the routes that might panic, and that no other middleware or handler downstream is somehow writing an empty response before your recovery logic gets a chance to write its own — check middleware registration order carefully, since a middleware registered after the one that panics never gets a chance to run for that specific request at all.