Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,6 @@ go tool pprof mem.prof
Remember: You're Bolt, making switchAILocal lightning fast. But speed without correctness is useless. Measure, optimize, verify.

**If you can't find a clear performance win today, stop and do not create a PR.**
## 2026-08-12 - sync.Pool in ResponseWriterWrapper
**Learning:** When using `sync.Pool` to recycle `bytes.Buffer` within HTTP middleware wrappers (like Gin's ResponseWriter), the `Put` operation must be placed inside a `defer` block at the beginning of the finalization method. Placing it at the end of the method leaves the buffer abandoned if the method hits an early return (e.g. for unlogged requests), defeating the optimization for those paths.
**Action:** Always wrap `sync.Pool.Put` in a `defer` when placing it in functions with multiple return paths to ensure buffers are consistently reclaimed, checking first that the buffer isn't nil and hasn't exceeded its capacity limit.
29 changes: 27 additions & 2 deletions internal/api/middleware/response_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ import (
"bytes"
"net/http"
"strings"
"sync"

"github.com/gin-gonic/gin"
"github.com/traylinx/switchAILocal/internal/interfaces"
"github.com/traylinx/switchAILocal/internal/logging"
)

// responseBodyPool is a sync.Pool for bytes.Buffer used to store response bodies.
// This reduces garbage collection pressure by reusing buffers across requests.
var responseBodyPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}

// RequestInfo holds essential details of an incoming HTTP request for logging purposes.
type RequestInfo struct {
URL string // URL is the request URL.
Expand Down Expand Up @@ -53,9 +62,12 @@ type ResponseWriterWrapper struct {
// Returns:
// - A pointer to a new ResponseWriterWrapper.
func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {
buf := responseBodyPool.Get().(*bytes.Buffer)
buf.Reset()

return &ResponseWriterWrapper{
ResponseWriter: w,
body: &bytes.Buffer{},
body: buf,
logger: logger,
requestInfo: requestInfo,
headers: make(map[string][]string),
Expand Down Expand Up @@ -246,6 +258,15 @@ func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
// For non-streaming responses, it logs the complete request and response details,
// including any API-specific request/response data stored in the Gin context.
func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
if w.body != nil {
defer func() {
if w.body.Cap() <= 128*1024 {
responseBodyPool.Put(w.body)
}
w.body = nil
}()
}

if w.logger == nil {
return nil
}
Expand Down Expand Up @@ -301,7 +322,11 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
return nil
}

return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog)
// Capture body bytes before returning buffer to pool
// Copy the bytes since the buffer will be reused
bodyBytes := append([]byte(nil), w.body.Bytes()...)

return w.logRequest(finalStatusCode, w.cloneHeaders(), bodyBytes, w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog)
}

func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {
Expand Down
Loading