samsa/internal/autocomplete/service.go
Franz Kafka 9e95ce7b53 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.
2026-03-23 14:26:26 +00:00

139 lines
3.6 KiB
Go

// 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 autocomplete
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/metamorphosis-dev/samsa/internal/httpclient"
)
// Service fetches search suggestions from upstream or Wikipedia OpenSearch.
type Service struct {
upstreamURL string
http *http.Client
}
func NewService(upstreamURL string, timeout time.Duration) *Service {
if timeout <= 0 {
timeout = 5 * time.Second
}
return &Service{
upstreamURL: strings.TrimRight(upstreamURL, "/"),
http: httpclient.NewClient(timeout),
}
}
func (s *Service) Suggestions(ctx context.Context, query string) ([]string, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
if s.upstreamURL != "" {
return s.upstreamSuggestions(ctx, query)
}
return s.wikipediaSuggestions(ctx, query)
}
func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]string, error) {
u := s.upstreamURL + "/autocompleter?" + url.Values{"q": {query}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := s.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("upstream autocompleter failed")
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, err
}
// The /autocompleter endpoint returns a plain JSON array of strings.
var out []string
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
return out, nil
}
// wikipediaSuggestions fetches suggestions from Wikipedia's OpenSearch API.
func (s *Service) wikipediaSuggestions(ctx context.Context, query string) ([]string, error) {
u := "https://en.wikipedia.org/w/api.php?" + url.Values{
"action": {"opensearch"},
"format": {"json"},
"formatversion": {"2"},
"search": {query},
"namespace": {"0"},
"limit": {"10"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set(
"User-Agent",
"gosearch-go/0.1 (compatible; +https://github.com/metamorphosis-dev/samsa)",
)
resp, err := s.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("wikipedia opensearch failed")
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, err
}
// Wikipedia OpenSearch returns: [query, [suggestions], ...]
var data []json.RawMessage
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
if len(data) < 2 {
return nil, nil
}
var suggestions []string
if err := json.Unmarshal(data[1], &suggestions); err != nil {
return nil, err
}
return suggestions, nil
}