on main help sign in
perf: optimize HTTP client pooling and binary file detection

- Add HTTP client pooling with connection reuse for fetch and sourcegraph tools - Implement timeout-specific client caching to avoid creating new clients - Add binary file detection in grep tool to skip searching non-text files - Configure transport settings for better connection management

commitb66be3b7240412e3c72adfcd922644d92d079bd6 authorRaphael Amorim <rapha850@gmail.com> dateMon 16 Jun 2025, 15:40 (1 year ago) parentd9577ec
3 files changed+149−23
internal/llm/tools/fetch.go+52−12 internal/llm/tools/grep.go+46 internal/llm/tools/sourcegraph.go+51−11
internal/llm/tools/fetch.go+52−12
@@ -7,6 +7,7 @@ import (
77 "io"
88 "net/http"
99 "strings"
10+ "sync"
1011 "time"
1112
1213 md "github.com/JohannesKaufmann/html-to-markdown"
@@ -28,8 +29,10 @@ type FetchPermissionsParams struct {
2829}
2930
3031type fetchTool struct {
31− client *http.Client
32− permissions permission.Service
32+ client *http.Client
33+ clientPool map[int]*http.Client
34+ clientPoolMu sync.RWMutex
35+ permissions permission.Service
3336}
3437
3538const (
@@ -69,11 +72,57 @@ func NewFetchTool(permissions permission.Service) BaseTool {
6972 return &fetchTool{
7073 client: &http.Client{
7174 Timeout: 30 * time.Second,
75+ Transport: &http.Transport{
76+ MaxIdleConns: 100,
77+ MaxIdleConnsPerHost: 10,
78+ IdleConnTimeout: 90 * time.Second,
79+ },
7280 },
81+ clientPool: make(map[int]*http.Client),
7382 permissions: permissions,
7483 }
7584}
7685
86+// getClientForTimeout returns a cached client for the given timeout or the default client
87+func (t *fetchTool) getClientForTimeout(timeout int) *http.Client {
88+ if timeout <= 0 {
89+ return t.client
90+ }
91+
92+ maxTimeout := 120 // 2 minutes
93+ if timeout > maxTimeout {
94+ timeout = maxTimeout
95+ }
96+
97+ // Check if we have a cached client for this timeout
98+ t.clientPoolMu.RLock()
99+ if client, exists := t.clientPool[timeout]; exists {
100+ t.clientPoolMu.RUnlock()
101+ return client
102+ }
103+ t.clientPoolMu.RUnlock()
104+
105+ // Create and cache a new client
106+ t.clientPoolMu.Lock()
107+ defer t.clientPoolMu.Unlock()
108+
109+ // Double-check in case another goroutine created it
110+ if client, exists := t.clientPool[timeout]; exists {
111+ return client
112+ }
113+
114+ client := &http.Client{
115+ Timeout: time.Duration(timeout) * time.Second,
116+ Transport: &http.Transport{
117+ MaxIdleConns: 100,
118+ MaxIdleConnsPerHost: 10,
119+ IdleConnTimeout: 90 * time.Second,
120+ },
121+ }
122+ t.clientPool[timeout] = client
123+ return client
124+}
125+
77126func (t *fetchTool) Info() ToolInfo {
78127 return ToolInfo{
79128 Name: FetchToolName,
@@ -136,16 +185,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
136185 return ToolResponse{}, permission.ErrorPermissionDenied
137186 }
138187
139− client := t.client
140− if params.Timeout > 0 {
141− maxTimeout := 120 // 2 minutes
142− if params.Timeout > maxTimeout {
143− params.Timeout = maxTimeout
144− }
145− client = &http.Client{
146− Timeout: time.Duration(params.Timeout) * time.Second,
147− }
148− }
188+ client := t.getClientForTimeout(params.Timeout)
149189
150190 req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
151191 if err != nil {
internal/llm/tools/grep.go+46
@@ -5,6 +5,7 @@ import (
55 "context"
66 "encoding/json"
77 "fmt"
8+ "io"
89 "os"
910 "os/exec"
1011 "path/filepath"
@@ -377,6 +378,11 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
377378}
378379
379380func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
381+ // Quick binary file detection
382+ if isBinaryFile(filePath) {
383+ return false, 0, "", nil
384+ }
385+
380386 file, err := os.Open(filePath)
381387 if err != nil {
382388 return false, 0, "", err
@@ -396,6 +402,46 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
396402 return false, 0, "", scanner.Err()
397403}
398404
405+// isBinaryFile performs a quick check to determine if a file is binary
406+func isBinaryFile(filePath string) bool {
407+ // Check file extension first (fastest)
408+ ext := strings.ToLower(filepath.Ext(filePath))
409+ binaryExts := map[string]bool{
410+ ".exe": true, ".dll": true, ".so": true, ".dylib": true,
411+ ".bin": true, ".obj": true, ".o": true, ".a": true,
412+ ".zip": true, ".tar": true, ".gz": true, ".bz2": true,
413+ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
414+ ".pdf": true, ".doc": true, ".docx": true, ".xls": true,
415+ ".mp3": true, ".mp4": true, ".avi": true, ".mov": true,
416+ }
417+ if binaryExts[ext] {
418+ return true
419+ }
420+
421+ // Quick content check for files without clear extensions
422+ file, err := os.Open(filePath)
423+ if err != nil {
424+ return false // If we can't open it, let the caller handle the error
425+ }
426+ defer file.Close()
427+
428+ // Read first 512 bytes to check for null bytes
429+ buffer := make([]byte, 512)
430+ n, err := file.Read(buffer)
431+ if err != nil && err != io.EOF {
432+ return false
433+ }
434+
435+ // Check for null bytes (common in binary files)
436+ for i := 0; i < n; i++ {
437+ if buffer[i] == 0 {
438+ return true
439+ }
440+ }
441+
442+ return false
443+}
444+
399445func globToRegex(glob string) string {
400446 regexPattern := strings.ReplaceAll(glob, ".", "\\.")
401447 regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
internal/llm/tools/sourcegraph.go+51−11
@@ -8,6 +8,7 @@ import (
88 "io"
99 "net/http"
1010 "strings"
11+ "sync"
1112 "time"
1213)
1314
@@ -24,7 +25,9 @@ type SourcegraphResponseMetadata struct {
2425}
2526
2627type sourcegraphTool struct {
27− client *http.Client
28+ client *http.Client
29+ clientPool map[int]*http.Client
30+ clientPoolMu sync.RWMutex
2831}
2932
3033const (
@@ -129,10 +132,56 @@ func NewSourcegraphTool() BaseTool {
129132 return &sourcegraphTool{
130133 client: &http.Client{
131134 Timeout: 30 * time.Second,
135+ Transport: &http.Transport{
136+ MaxIdleConns: 100,
137+ MaxIdleConnsPerHost: 10,
138+ IdleConnTimeout: 90 * time.Second,
139+ },
132140 },
141+ clientPool: make(map[int]*http.Client),
133142 }
134143}
135144
145+// getClientForTimeout returns a cached client for the given timeout or the default client
146+func (t *sourcegraphTool) getClientForTimeout(timeout int) *http.Client {
147+ if timeout <= 0 {
148+ return t.client
149+ }
150+
151+ maxTimeout := 120 // 2 minutes
152+ if timeout > maxTimeout {
153+ timeout = maxTimeout
154+ }
155+
156+ // Check if we have a cached client for this timeout
157+ t.clientPoolMu.RLock()
158+ if client, exists := t.clientPool[timeout]; exists {
159+ t.clientPoolMu.RUnlock()
160+ return client
161+ }
162+ t.clientPoolMu.RUnlock()
163+
164+ // Create and cache a new client
165+ t.clientPoolMu.Lock()
166+ defer t.clientPoolMu.Unlock()
167+
168+ // Double-check in case another goroutine created it
169+ if client, exists := t.clientPool[timeout]; exists {
170+ return client
171+ }
172+
173+ client := &http.Client{
174+ Timeout: time.Duration(timeout) * time.Second,
175+ Transport: &http.Transport{
176+ MaxIdleConns: 100,
177+ MaxIdleConnsPerHost: 10,
178+ IdleConnTimeout: 90 * time.Second,
179+ },
180+ }
181+ t.clientPool[timeout] = client
182+ return client
183+}
184+
136185func (t *sourcegraphTool) Info() ToolInfo {
137186 return ToolInfo{
138187 Name: SourcegraphToolName,
@@ -178,16 +227,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
178227 if params.ContextWindow <= 0 {
179228 params.ContextWindow = 10 // Default context window
180229 }
181− client := t.client
182− if params.Timeout > 0 {
183− maxTimeout := 120 // 2 minutes
184− if params.Timeout > maxTimeout {
185− params.Timeout = maxTimeout
186− }
187− client = &http.Client{
188− Timeout: time.Duration(params.Timeout) * time.Second,
189− }
190− }
230+ client := t.getClientForTimeout(params.Timeout)
191231
192232 type graphqlRequest struct {
193233 Query string `json:"query"`