diff --git a/.env.example b/.env.example index 25cbb818..3fe516c1 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,10 @@ PROXY_URL= EXTERNAL_SSL_CA_PATH= EXTERNAL_SSL_INSECURE= +## HTTP client timeout in seconds for external API calls (LLM providers, search tools, etc.) +## Default: 600 (10 minutes). Set to 0 to use the default. +HTTP_CLIENT_TIMEOUT=600 + ## Scraper URLs and settings ## For Docker (default): SCRAPER_PUBLIC_URL= diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 3b4cc7e1..b7a6afb0 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -190,6 +190,10 @@ type Config struct { ExternalSSLCAPath string `env:"EXTERNAL_SSL_CA_PATH" envDefault:""` ExternalSSLInsecure bool `env:"EXTERNAL_SSL_INSECURE" envDefault:"false"` + // HTTP client timeout in seconds for external API calls (LLM providers, search tools, etc.) + // A value of 0 means no timeout (not recommended). + HTTPClientTimeout int `env:"HTTP_CLIENT_TIMEOUT" envDefault:"600"` + // Telemetry (observability OpenTelemetry collector) TelemetryEndpoint string `env:"OTEL_HOST"` diff --git a/backend/pkg/system/utils.go b/backend/pkg/system/utils.go index 18909c0d..2585036f 100644 --- a/backend/pkg/system/utils.go +++ b/backend/pkg/system/utils.go @@ -8,10 +8,16 @@ import ( "net/http" "net/url" "os" + "time" "pentagi/pkg/config" ) +const ( + // defaultHTTPClientTimeout is the fallback timeout when no config is provided. + defaultHTTPClientTimeout = 10 * time.Minute +) + func getHostname() string { hn, err := os.Hostname() if err != nil { @@ -65,7 +71,9 @@ func GetHTTPClient(cfg *config.Config) (*http.Client, error) { var httpClient *http.Client if cfg == nil { - return http.DefaultClient, nil + return &http.Client{ + Timeout: defaultHTTPClientTimeout, + }, nil } rootCAPool, err := GetSystemCertPool(cfg) @@ -73,8 +81,14 @@ func GetHTTPClient(cfg *config.Config) (*http.Client, error) { return nil, err } + timeout := defaultHTTPClientTimeout + if cfg.HTTPClientTimeout > 0 { + timeout = time.Duration(cfg.HTTPClientTimeout) * time.Second + } + if cfg.ProxyURL != "" { httpClient = &http.Client{ + Timeout: timeout, Transport: &http.Transport{ Proxy: func(req *http.Request) (*url.URL, error) { return url.Parse(cfg.ProxyURL) @@ -87,6 +101,7 @@ func GetHTTPClient(cfg *config.Config) (*http.Client, error) { } } else { httpClient = &http.Client{ + Timeout: timeout, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: rootCAPool, diff --git a/backend/pkg/system/utils_test.go b/backend/pkg/system/utils_test.go index 0f36fbe2..168a0c73 100644 --- a/backend/pkg/system/utils_test.go +++ b/backend/pkg/system/utils_test.go @@ -211,6 +211,7 @@ func createTestConfig(caPath string, insecure bool, proxyURL string) *config.Con ExternalSSLCAPath: caPath, ExternalSSLInsecure: insecure, ProxyURL: proxyURL, + HTTPClientTimeout: 600, // default 10 minutes } } @@ -635,6 +636,83 @@ func TestHTTPClient_RealConnection_MultipleRootCAs(t *testing.T) { } } +func TestGetHTTPClient_NilConfig(t *testing.T) { + client, err := GetHTTPClient(nil) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if client == nil { + t.Fatal("expected non-nil HTTP client") + } + + if client.Timeout != defaultHTTPClientTimeout { + t.Errorf("expected default timeout %v, got %v", defaultHTTPClientTimeout, client.Timeout) + } +} + +func TestGetHTTPClient_DefaultTimeout(t *testing.T) { + cfg := createTestConfig("", false, "") + + client, err := GetHTTPClient(cfg) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + expected := 600 * time.Second + if client.Timeout != expected { + t.Errorf("expected timeout %v, got %v", expected, client.Timeout) + } +} + +func TestGetHTTPClient_CustomTimeout(t *testing.T) { + cfg := &config.Config{ + HTTPClientTimeout: 120, + } + + client, err := GetHTTPClient(cfg) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + expected := 120 * time.Second + if client.Timeout != expected { + t.Errorf("expected timeout %v, got %v", expected, client.Timeout) + } +} + +func TestGetHTTPClient_ZeroTimeoutUsesDefault(t *testing.T) { + cfg := &config.Config{ + HTTPClientTimeout: 0, + } + + client, err := GetHTTPClient(cfg) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if client.Timeout != defaultHTTPClientTimeout { + t.Errorf("expected default timeout %v for zero config, got %v", defaultHTTPClientTimeout, client.Timeout) + } +} + +func TestGetHTTPClient_TimeoutWithProxy(t *testing.T) { + cfg := &config.Config{ + HTTPClientTimeout: 300, + ProxyURL: "http://proxy.example.com:8080", + } + + client, err := GetHTTPClient(cfg) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + expected := 300 * time.Second + if client.Timeout != expected { + t.Errorf("expected timeout %v with proxy, got %v", expected, client.Timeout) + } +} + func TestHTTPClient_RealConnection_InsecureMode(t *testing.T) { certs, err := generateTestCerts() if err != nil {