first commit
This commit is contained in:
417
core/network/http.go
Normal file
417
core/network/http.go
Normal file
@@ -0,0 +1,417 @@
|
||||
// Package network provides centralized HTTP client management with proxy support.
|
||||
// It allows runtime proxy configuration updates that propagate to all HTTP clients.
|
||||
package network
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||
)
|
||||
|
||||
// ClientPurpose defines the intended use of an HTTP client for proxy filtering
|
||||
type ClientPurpose string
|
||||
|
||||
const (
|
||||
// ClientPurposeSCIM is used for SCIM/OAuth provider requests
|
||||
ClientPurposeSCIM ClientPurpose = "scim"
|
||||
// ClientPurposeInference is used for LLM inference requests
|
||||
ClientPurposeInference ClientPurpose = "inference"
|
||||
// ClientPurposeAPI is used for general API requests (guardrails, etc.)
|
||||
ClientPurposeAPI ClientPurpose = "api"
|
||||
)
|
||||
|
||||
// DefaultClientConfig holds default timeout values for HTTP clients
|
||||
var DefaultClientConfig = struct {
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
MaxIdleConnDuration time.Duration
|
||||
MaxConnDuration time.Duration
|
||||
MaxConnsPerHost int
|
||||
}{
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
MaxIdleConnDuration: 30 * time.Second,
|
||||
MaxConnDuration: 300 * time.Second,
|
||||
MaxConnsPerHost: 200,
|
||||
}
|
||||
|
||||
// GlobalProxyType represents the type of global proxy
|
||||
type GlobalProxyType string
|
||||
|
||||
const (
|
||||
GlobalProxyTypeHTTP GlobalProxyType = "http"
|
||||
GlobalProxyTypeSOCKS5 GlobalProxyType = "socks5"
|
||||
GlobalProxyTypeTCP GlobalProxyType = "tcp"
|
||||
)
|
||||
|
||||
// GlobalProxyConfig represents the global proxy configuration
|
||||
type GlobalProxyConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Type GlobalProxyType `json:"type"` // "http", "socks5", "tcp"
|
||||
URL string `json:"url"` // Proxy URL (e.g., http://proxy.example.com:8080)
|
||||
Username string `json:"username,omitempty"` // Optional authentication username
|
||||
Password string `json:"password,omitempty"` // Optional authentication password
|
||||
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated list of hosts to bypass proxy
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds
|
||||
SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` // Skip TLS certificate verification
|
||||
// Entity enablement flags
|
||||
EnableForSCIM bool `json:"enable_for_scim"` // Enable proxy for SCIM requests (enterprise only)
|
||||
EnableForInference bool `json:"enable_for_inference"` // Enable proxy for inference requests
|
||||
EnableForAPI bool `json:"enable_for_api"` // Enable proxy for API requests
|
||||
}
|
||||
|
||||
// HTTPClientFactory manages HTTP clients with centralized proxy configuration.
|
||||
// It supports both fasthttp and standard net/http clients with purpose-based
|
||||
// proxy enablement (SCIM, Inference, API).
|
||||
type HTTPClientFactory struct {
|
||||
mu sync.RWMutex
|
||||
proxyConfig *GlobalProxyConfig
|
||||
|
||||
// Cached clients per purpose - lazily initialized
|
||||
fasthttpClients map[ClientPurpose]*fasthttp.Client
|
||||
httpClients map[ClientPurpose]*http.Client
|
||||
|
||||
logger schemas.Logger
|
||||
}
|
||||
|
||||
// NewHTTPClientFactory creates a new HTTP client factory with the given proxy configuration.
|
||||
// Pass nil for proxyConfig if proxy is not yet configured.
|
||||
func NewHTTPClientFactory(proxyConfig *GlobalProxyConfig, logger schemas.Logger) *HTTPClientFactory {
|
||||
return &HTTPClientFactory{
|
||||
proxyConfig: proxyConfig,
|
||||
fasthttpClients: make(map[ClientPurpose]*fasthttp.Client, 3),
|
||||
httpClients: make(map[ClientPurpose]*http.Client, 3),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProxyConfig updates the proxy configuration and recreates all cached clients.
|
||||
// This is thread-safe and can be called at runtime.
|
||||
func (f *HTTPClientFactory) UpdateProxyConfig(config *GlobalProxyConfig) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.proxyConfig = config
|
||||
|
||||
// Clear cached clients - they will be recreated on next request
|
||||
f.fasthttpClients = make(map[ClientPurpose]*fasthttp.Client, 3)
|
||||
f.httpClients = make(map[ClientPurpose]*http.Client, 3)
|
||||
}
|
||||
|
||||
// GetProxyConfig returns the current proxy configuration (thread-safe read)
|
||||
func (f *HTTPClientFactory) GetProxyConfig() *GlobalProxyConfig {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
return f.proxyConfig
|
||||
}
|
||||
|
||||
// isProxyEnabledForPurpose checks if proxy should be used for the given purpose
|
||||
func (f *HTTPClientFactory) isProxyEnabledForPurpose(purpose ClientPurpose) bool {
|
||||
if f.proxyConfig == nil || !f.proxyConfig.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
switch purpose {
|
||||
case ClientPurposeSCIM:
|
||||
return f.proxyConfig.EnableForSCIM
|
||||
case ClientPurposeInference:
|
||||
return f.proxyConfig.EnableForInference
|
||||
case ClientPurposeAPI:
|
||||
return f.proxyConfig.EnableForAPI
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// shouldBypassProxy checks if a host matches a noProxy pattern
|
||||
// Supported patterns:
|
||||
// - "*" matches all hosts
|
||||
// - ".example.com" matches example.com and all subdomains
|
||||
// - "*.example.com" matches subdomains of example.com only
|
||||
// - exact host match
|
||||
func shouldBypassProxy(host, pattern string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
if pattern == host {
|
||||
return true
|
||||
}
|
||||
// .example.com matches example.com and *.example.com
|
||||
if strings.HasPrefix(pattern, ".") {
|
||||
suffix := pattern[1:] // remove leading dot
|
||||
return host == suffix || strings.HasSuffix(host, pattern)
|
||||
}
|
||||
// *.example.com matches subdomains only
|
||||
if strings.HasPrefix(pattern, "*.") {
|
||||
suffix := pattern[1:] // keep the dot, e.g., ".example.com"
|
||||
return strings.HasSuffix(host, suffix)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFasthttpClient returns a fasthttp client configured for the given purpose.
|
||||
// If proxy is enabled for this purpose, the client will be configured with proxy settings.
|
||||
// Clients are cached and reused until proxy config changes.
|
||||
func (f *HTTPClientFactory) GetFasthttpClient(purpose ClientPurpose) *fasthttp.Client {
|
||||
f.mu.RLock()
|
||||
if client, ok := f.fasthttpClients[purpose]; ok {
|
||||
f.mu.RUnlock()
|
||||
return client
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if client, ok := f.fasthttpClients[purpose]; ok {
|
||||
return client
|
||||
}
|
||||
|
||||
client := f.createFasthttpClient(purpose)
|
||||
f.fasthttpClients[purpose] = client
|
||||
return client
|
||||
}
|
||||
|
||||
// GetHTTPClient returns a standard net/http client configured for the given purpose.
|
||||
// If proxy is enabled for this purpose, the client will be configured with proxy settings.
|
||||
// Clients are cached and reused until proxy config changes.
|
||||
func (f *HTTPClientFactory) GetHTTPClient(purpose ClientPurpose) *http.Client {
|
||||
f.mu.RLock()
|
||||
if client, ok := f.httpClients[purpose]; ok {
|
||||
f.mu.RUnlock()
|
||||
return client
|
||||
}
|
||||
f.mu.RUnlock()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if client, ok := f.httpClients[purpose]; ok {
|
||||
return client
|
||||
}
|
||||
|
||||
client := f.createHTTPClient(purpose)
|
||||
f.httpClients[purpose] = client
|
||||
return client
|
||||
}
|
||||
|
||||
// createFasthttpClient creates a new fasthttp client with appropriate proxy settings
|
||||
func (f *HTTPClientFactory) createFasthttpClient(purpose ClientPurpose) *fasthttp.Client {
|
||||
client := &fasthttp.Client{
|
||||
ReadTimeout: DefaultClientConfig.ReadTimeout,
|
||||
WriteTimeout: DefaultClientConfig.WriteTimeout,
|
||||
MaxIdleConnDuration: DefaultClientConfig.MaxIdleConnDuration,
|
||||
MaxConnDuration: DefaultClientConfig.MaxConnDuration,
|
||||
MaxConnsPerHost: DefaultClientConfig.MaxConnsPerHost,
|
||||
MaxConnWaitTimeout: DefaultClientConfig.ReadTimeout,
|
||||
ConnPoolStrategy: fasthttp.FIFO,
|
||||
RetryIfErr: StaleConnectionRetryIfErr,
|
||||
}
|
||||
|
||||
// Configure proxy if enabled for this purpose
|
||||
if f.isProxyEnabledForPurpose(purpose) {
|
||||
f.configureFasthttpProxy(client)
|
||||
}
|
||||
|
||||
// Configure TLS if skip verification is set
|
||||
if f.proxyConfig != nil {
|
||||
if f.proxyConfig.SkipTLSVerify {
|
||||
f.logger.Warn("skipping TLS verification for fasthttp client because skip TLS verify is set to true. It's not recommended to use this in production.")
|
||||
}
|
||||
client.TLSConfig = &tls.Config{
|
||||
InsecureSkipVerify: f.proxyConfig.SkipTLSVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// StaleConnectionRetryIfErr is a RetryIfErr callback that retries requests when the failure
|
||||
// is due to a stale/dead connection being reused from the pool. This addresses intermittent
|
||||
// "cannot find whitespace in the first line of response" errors caused by connection reuse
|
||||
// with leftover chunked transfer encoding data (see: https://github.com/valyala/fasthttp/issues/1743).
|
||||
//
|
||||
// By default fasthttp only retries idempotent requests (GET/HEAD/PUT). LLM inference requests
|
||||
// use POST, so without this they fail immediately on stale connections. Retrying is safe here
|
||||
// because the error occurs during response header parsing — before the server processes the
|
||||
// new request, or on a connection the server has already closed.
|
||||
func StaleConnectionRetryIfErr(_ *fasthttp.Request, attempts int, err error) (resetTimeout bool, retry bool) {
|
||||
if attempts > 1 {
|
||||
return false, false
|
||||
}
|
||||
if err == nil {
|
||||
return false, false
|
||||
}
|
||||
errStr := err.Error()
|
||||
// io.EOF — server closed the connection (fasthttp converts this to
|
||||
// ErrConnectionClosed AFTER the retry loop, so RetryIfErr sees raw EOF)
|
||||
// "cannot find whitespace in the first line of response" — stale chunked data in buffer
|
||||
// "connection reset by peer" — server RST'd the idle connection (read-side)
|
||||
// "broken pipe" — server closed the idle connection (write-side EPIPE)
|
||||
if err == io.EOF ||
|
||||
strings.Contains(errStr, "cannot find whitespace") ||
|
||||
strings.Contains(errStr, "connection reset by peer") ||
|
||||
strings.Contains(errStr, "broken pipe") {
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// buildProxyURLWithAuth adds authentication to a proxy URL if credentials are provided
|
||||
func (f *HTTPClientFactory) buildProxyURLWithAuth() string {
|
||||
proxyURL := f.proxyConfig.URL
|
||||
if f.proxyConfig.Username != "" && f.proxyConfig.Password != "" {
|
||||
parsedURL, err := url.Parse(f.proxyConfig.URL)
|
||||
if err == nil {
|
||||
parsedURL.User = url.UserPassword(f.proxyConfig.Username, f.proxyConfig.Password)
|
||||
proxyURL = parsedURL.String()
|
||||
}
|
||||
}
|
||||
return proxyURL
|
||||
}
|
||||
|
||||
// configureFasthttpProxy configures proxy for a fasthttp client
|
||||
func (f *HTTPClientFactory) configureFasthttpProxy(client *fasthttp.Client) {
|
||||
if f.proxyConfig == nil || f.proxyConfig.URL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
proxyURL := f.buildProxyURLWithAuth()
|
||||
var dialFunc fasthttp.DialFunc
|
||||
|
||||
switch f.proxyConfig.Type {
|
||||
case GlobalProxyTypeHTTP:
|
||||
dialFunc = fasthttpproxy.FasthttpHTTPDialer(proxyURL)
|
||||
case GlobalProxyTypeSOCKS5:
|
||||
dialFunc = fasthttpproxy.FasthttpSocksDialer(proxyURL)
|
||||
}
|
||||
|
||||
proxyCfg := f.proxyConfig
|
||||
if dialFunc != nil {
|
||||
client.Dial = func(addr string) (net.Conn, error) {
|
||||
if proxyCfg.NoProxy != "" {
|
||||
host := strings.Split(addr, ":")[0]
|
||||
if host == "" {
|
||||
host = addr
|
||||
}
|
||||
if shouldBypassProxy(host, proxyCfg.NoProxy) {
|
||||
return net.Dial("tcp", addr)
|
||||
}
|
||||
}
|
||||
return dialFunc(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createHTTPClient creates a new standard net/http client with appropriate proxy settings
|
||||
func (f *HTTPClientFactory) createHTTPClient(purpose ClientPurpose) *http.Client {
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: DefaultClientConfig.MaxConnsPerHost,
|
||||
MaxIdleConnsPerHost: DefaultClientConfig.MaxConnsPerHost,
|
||||
IdleConnTimeout: DefaultClientConfig.MaxIdleConnDuration,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: DefaultClientConfig.ReadTimeout,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableCompression: false,
|
||||
DisableKeepAlives: false,
|
||||
// Disable HTTP/2 — these clients are used for auxiliary purposes (proxy/SCIM/API)
|
||||
// where HTTP/1.1 is sufficient. Without this, Go's http2 package auto-registers
|
||||
// h2 via TLSNextProto in init(), causing unintended HTTP/2 connections.
|
||||
TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper),
|
||||
}
|
||||
|
||||
// Configure proxy if enabled for this purpose
|
||||
if f.isProxyEnabledForPurpose(purpose) {
|
||||
f.configureHTTPProxy(transport)
|
||||
}
|
||||
|
||||
// Configure TLS if skip verification is set
|
||||
if f.proxyConfig != nil {
|
||||
if f.proxyConfig.SkipTLSVerify {
|
||||
f.logger.Warn("skipping TLS verification for fasthttp client because skip TLS verify is set to true. It's not recommended to use this in production.")
|
||||
}
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: f.proxyConfig.SkipTLSVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
timeout := DefaultClientConfig.ReadTimeout
|
||||
if f.proxyConfig != nil && f.proxyConfig.Timeout > 0 {
|
||||
timeout = time.Duration(f.proxyConfig.Timeout) * time.Second
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// configureHTTPProxy configures proxy for a standard net/http transport
|
||||
func (f *HTTPClientFactory) configureHTTPProxy(transport *http.Transport) {
|
||||
if f.proxyConfig == nil || f.proxyConfig.URL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
proxyURL, err := url.Parse(f.proxyConfig.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Add authentication if provided
|
||||
if f.proxyConfig.Username != "" && f.proxyConfig.Password != "" {
|
||||
proxyURL.User = url.UserPassword(f.proxyConfig.Username, f.proxyConfig.Password)
|
||||
|
||||
// For HTTPS requests through HTTP proxy, the CONNECT method is used to establish a tunnel.
|
||||
// Proxy authentication must be sent via ProxyConnectHeader for the CONNECT request.
|
||||
// Without this, the proxy will reject/reset the connection before the TLS handshake.
|
||||
basicAuth := "Basic " + base64.StdEncoding.EncodeToString(
|
||||
[]byte(f.proxyConfig.Username+":"+f.proxyConfig.Password),
|
||||
)
|
||||
transport.ProxyConnectHeader = http.Header{
|
||||
"Proxy-Authorization": {basicAuth},
|
||||
}
|
||||
}
|
||||
|
||||
// Capture noProxy patterns at creation time to avoid data race with UpdateProxyConfig.
|
||||
// The closure below is called for each request and would otherwise read f.proxyConfig
|
||||
// concurrently with writes from UpdateProxyConfig.
|
||||
var noProxyPatterns []string
|
||||
if f.proxyConfig.NoProxy != "" {
|
||||
noProxyPatterns = strings.Split(f.proxyConfig.NoProxy, ",")
|
||||
}
|
||||
|
||||
proxyCfg := f.proxyConfig
|
||||
transport.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
// Use Hostname() to get the host without port for matching.
|
||||
// req.URL.Host is "host:port" but no_proxy patterns are host-only.
|
||||
if proxyCfg.NoProxy != "" {
|
||||
host := req.URL.Hostname()
|
||||
if host == "" {
|
||||
host = req.URL.Host
|
||||
}
|
||||
for _, np := range noProxyPatterns {
|
||||
if shouldBypassProxy(host, np) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return proxyURL, nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user