perf: shared http.Transport with tuned connection pooling
Add internal/httpclient package as a singleton RoundTripper used by all outbound engine requests (search, engines, autocomplete, upstream). Key Transport settings: - MaxIdleConnsPerHost = 20 (up from Go default of 2) - MaxIdleConns = 100 - IdleConnTimeout = 90s - DialContext timeout = 5s Previously, the default transport limited each host to 2 idle connections, forcing a new TCP+TLS handshake on every search for each engine. With 12 engines hitting the same upstream hosts in parallel, connections were constantly recycled. Now warm connections are reused across all goroutines and requests.
This commit is contained in:
parent
7ea50d3123
commit
9e95ce7b53
6 changed files with 100 additions and 7 deletions
|
|
@ -25,6 +25,8 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/metamorphosis-dev/samsa/internal/httpclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service fetches search suggestions from upstream or Wikipedia OpenSearch.
|
// Service fetches search suggestions from upstream or Wikipedia OpenSearch.
|
||||||
|
|
@ -39,7 +41,7 @@ func NewService(upstreamURL string, timeout time.Duration) *Service {
|
||||||
}
|
}
|
||||||
return &Service{
|
return &Service{
|
||||||
upstreamURL: strings.TrimRight(upstreamURL, "/"),
|
upstreamURL: strings.TrimRight(upstreamURL, "/"),
|
||||||
http: &http.Client{Timeout: timeout},
|
http: httpclient.NewClient(timeout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/metamorphosis-dev/samsa/internal/config"
|
"github.com/metamorphosis-dev/samsa/internal/config"
|
||||||
|
"github.com/metamorphosis-dev/samsa/internal/httpclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDefaultPortedEngines returns the Go-native engine registry.
|
// NewDefaultPortedEngines returns the Go-native engine registry.
|
||||||
// If cfg is nil, API keys fall back to environment variables.
|
// If cfg is nil, API keys fall back to environment variables.
|
||||||
func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string]Engine {
|
func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string]Engine {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 10 * time.Second}
|
client = httpclient.NewClient(10 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
var braveAPIKey, braveAccessToken, youtubeAPIKey string
|
var braveAPIKey, braveAccessToken, youtubeAPIKey string
|
||||||
|
|
|
||||||
61
internal/httpclient/client.go
Normal file
61
internal/httpclient/client.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// samsa — a privacy-respecting metasearch engine
|
||||||
|
// Copyright (C) 2026-present metamorphosis-dev
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package httpclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultTransport http.RoundTripper
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default returns a shared, pre-configured http.RoundTripper suitable for
|
||||||
|
// outgoing engine requests. It is safe for concurrent use across goroutines.
|
||||||
|
// All fields are tuned for a meta-search engine that makes many concurrent
|
||||||
|
// requests to a fixed set of upstream hosts:
|
||||||
|
//
|
||||||
|
// - MaxIdleConnsPerHost = 20 (vs default of 2; keeps more warm connections
|
||||||
|
// to each host, avoiding repeated TCP+TLS handshakes)
|
||||||
|
// - MaxIdleConns = 100 (total idle connection ceiling)
|
||||||
|
// - IdleConnTimeout = 90s (prunes connections before they go stale)
|
||||||
|
// - DialContext timeout = 5s (fails fast on DNS/connect rather than
|
||||||
|
// holding a goroutine indefinitely)
|
||||||
|
func Default() http.RoundTripper {
|
||||||
|
once.Do(func() {
|
||||||
|
defaultTransport = &http.Transport{
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
DialContext: dialWithTimeout(5 * time.Second),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return defaultTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient returns an http.Client that uses DefaultTransport and the given
|
||||||
|
// request timeout. The returned client reuses the shared connection pool,
|
||||||
|
// so all clients created via this function share the same warm connections.
|
||||||
|
func NewClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: Default(),
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/httpclient/dial.go
Normal file
30
internal/httpclient/dial.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// samsa — a privacy-respecting metasearch engine
|
||||||
|
// Copyright (C) 2026-present metamorphosis-dev
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package httpclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialWithTimeout returns a DialContext function for http.Transport that
|
||||||
|
// respects the given connection timeout.
|
||||||
|
func dialWithTimeout(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) {
|
||||||
|
d := &net.Dialer{Timeout: timeout}
|
||||||
|
return d.DialContext
|
||||||
|
}
|
||||||
|
|
@ -18,11 +18,11 @@ package search
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/metamorphosis-dev/samsa/internal/cache"
|
"github.com/metamorphosis-dev/samsa/internal/cache"
|
||||||
|
"github.com/metamorphosis-dev/samsa/internal/httpclient"
|
||||||
"github.com/metamorphosis-dev/samsa/internal/config"
|
"github.com/metamorphosis-dev/samsa/internal/config"
|
||||||
"github.com/metamorphosis-dev/samsa/internal/contracts"
|
"github.com/metamorphosis-dev/samsa/internal/contracts"
|
||||||
"github.com/metamorphosis-dev/samsa/internal/engines"
|
"github.com/metamorphosis-dev/samsa/internal/engines"
|
||||||
|
|
@ -49,7 +49,7 @@ func NewService(cfg ServiceConfig) *Service {
|
||||||
timeout = 10 * time.Second
|
timeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{Timeout: timeout}
|
httpClient := httpclient.NewClient(timeout)
|
||||||
|
|
||||||
var up *upstream.Client
|
var up *upstream.Client
|
||||||
if cfg.UpstreamURL != "" {
|
if cfg.UpstreamURL != "" {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/metamorphosis-dev/samsa/internal/contracts"
|
"github.com/metamorphosis-dev/samsa/internal/contracts"
|
||||||
|
"github.com/metamorphosis-dev/samsa/internal/httpclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
|
@ -56,9 +57,7 @@ func NewClient(baseURL string, timeout time.Duration) (*Client, error) {
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: base,
|
baseURL: base,
|
||||||
http: &http.Client{
|
http: httpclient.NewClient(timeout),
|
||||||
Timeout: timeout,
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue