diff --git a/internal/autocomplete/service.go b/internal/autocomplete/service.go index bc51026..fd3d8ea 100644 --- a/internal/autocomplete/service.go +++ b/internal/autocomplete/service.go @@ -25,6 +25,8 @@ import ( "net/url" "strings" "time" + + "github.com/metamorphosis-dev/samsa/internal/httpclient" ) // Service fetches search suggestions from upstream or Wikipedia OpenSearch. @@ -39,7 +41,7 @@ func NewService(upstreamURL string, timeout time.Duration) *Service { } return &Service{ upstreamURL: strings.TrimRight(upstreamURL, "/"), - http: &http.Client{Timeout: timeout}, + http: httpclient.NewClient(timeout), } } diff --git a/internal/engines/factory.go b/internal/engines/factory.go index ff0b925..f91cf1b 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -22,13 +22,14 @@ import ( "time" "github.com/metamorphosis-dev/samsa/internal/config" + "github.com/metamorphosis-dev/samsa/internal/httpclient" ) // NewDefaultPortedEngines returns the Go-native engine registry. // If cfg is nil, API keys fall back to environment variables. func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string]Engine { if client == nil { - client = &http.Client{Timeout: 10 * time.Second} + client = httpclient.NewClient(10 * time.Second) } var braveAPIKey, braveAccessToken, youtubeAPIKey string diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go new file mode 100644 index 0000000..450d121 --- /dev/null +++ b/internal/httpclient/client.go @@ -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 . + +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, + } +} diff --git a/internal/httpclient/dial.go b/internal/httpclient/dial.go new file mode 100644 index 0000000..c646e33 --- /dev/null +++ b/internal/httpclient/dial.go @@ -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 . + +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 +} diff --git a/internal/search/service.go b/internal/search/service.go index 29d7010..5759f60 100644 --- a/internal/search/service.go +++ b/internal/search/service.go @@ -18,11 +18,11 @@ package search import ( "context" - "net/http" "sync" "time" "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/contracts" "github.com/metamorphosis-dev/samsa/internal/engines" @@ -49,7 +49,7 @@ func NewService(cfg ServiceConfig) *Service { timeout = 10 * time.Second } - httpClient := &http.Client{Timeout: timeout} + httpClient := httpclient.NewClient(timeout) var up *upstream.Client if cfg.UpstreamURL != "" { diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 816b713..7c48311 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -28,6 +28,7 @@ import ( "time" "github.com/metamorphosis-dev/samsa/internal/contracts" + "github.com/metamorphosis-dev/samsa/internal/httpclient" ) type Client struct { @@ -56,9 +57,7 @@ func NewClient(baseURL string, timeout time.Duration) (*Client, error) { return &Client{ baseURL: base, - http: &http.Client{ - Timeout: timeout, - }, + http: httpclient.NewClient(timeout), }, nil }