Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions clients/http_json_rpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,29 @@ func NewGenericHttpJsonRpcClient(

// Default fallback transport (no proxy)
// Optimized for high-latency, high-RPS scenarios to prevent connection churn
var timeouts *common.HTTPClientTimeouts
if jsonRpcCfg != nil {
timeouts = &jsonRpcCfg.HTTPClientTimeouts
}
resolved := timeouts.Resolve()

logger.Debug().
Dur("timeout", resolved.Timeout).
Dur("responseHeaderTimeout", resolved.ResponseHeaderTimeout).
Dur("tlsHandshakeTimeout", resolved.TLSHandshakeTimeout).
Dur("idleConnTimeout", resolved.IdleConnTimeout).
Dur("expectContinueTimeout", resolved.ExpectContinueTimeout).
Str("upstreamId", upstream.Id()).
Msg("creating HTTP client with timeout configuration")

transport := &http.Transport{
MaxIdleConns: 1024,
MaxIdleConnsPerHost: 256,
MaxConnsPerHost: 0, // Unlimited active connections (prevents bottleneck)
IdleConnTimeout: 90 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: resolved.IdleConnTimeout,
ResponseHeaderTimeout: resolved.ResponseHeaderTimeout,
TLSHandshakeTimeout: resolved.TLSHandshakeTimeout,
ExpectContinueTimeout: resolved.ExpectContinueTimeout,
}

if util.IsTest() {
Expand All @@ -107,7 +122,7 @@ func NewGenericHttpJsonRpcClient(
}
} else {
client.httpClient = &http.Client{
Timeout: 60 * time.Second,
Timeout: resolved.Timeout,
Transport: transport,
}
}
Expand Down Expand Up @@ -203,14 +218,27 @@ func (c *GenericHttpJsonRpcClient) shutdown() {
func (c *GenericHttpJsonRpcClient) getHttpClient() *http.Client {
if c.proxyPool != nil {
client, err := c.proxyPool.GetClient()
if c.isLogLevelTrace {
proxy, _ := client.Transport.(*http.Transport).Proxy(nil)
c.logger.Trace().Str("proxyPool", c.proxyPool.ID).Str("ptr", fmt.Sprintf("%p", client.Transport)).Str("proxy", proxy.String()).Msgf("using client from proxy pool")
}
if err != nil {
c.logger.Error().Err(err).Msgf("failed to get client from proxy pool")
c.logger.Error().
Err(err).
Str("proxyPool", c.proxyPool.ID).
Str("upstreamId", c.upstream.Id()).
Bool("fallbackToDirectConnection", true).
Msg("failed to get client from proxy pool, falling back to direct connection")
return c.httpClient
}
if c.isLogLevelTrace {
if transport, ok := client.Transport.(*http.Transport); ok && transport != nil {
proxy, proxyErr := transport.Proxy(nil)
if proxyErr == nil && proxy != nil {
c.logger.Trace().
Str("proxyPool", c.proxyPool.ID).
Str("ptr", fmt.Sprintf("%p", client.Transport)).
Str("proxy", proxy.String()).
Msg("using client from proxy pool")
}
}
}
return client
}

Expand Down
21 changes: 14 additions & 7 deletions clients/proxy_pool_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"net/http"
"net/url"
"sync/atomic"
"time"

"github.com/erpc/erpc/common"
"github.com/rs/zerolog"
Expand Down Expand Up @@ -55,10 +54,17 @@ func NewProxyPoolRegistry(
return nil, err
}
r.pools[poolCfg.ID] = pool

resolved := poolCfg.HTTPClientTimeouts.Resolve()
logger.Debug().
Str("poolId", poolCfg.ID).
Int("clientCount", len(pool.clients)).
Msg("proxy pool created")
Dur("timeout", resolved.Timeout).
Dur("responseHeaderTimeout", resolved.ResponseHeaderTimeout).
Dur("tlsHandshakeTimeout", resolved.TLSHandshakeTimeout).
Dur("idleConnTimeout", resolved.IdleConnTimeout).
Dur("expectContinueTimeout", resolved.ExpectContinueTimeout).
Msg("proxy pool created with timeout configuration")
}

return r, nil
Expand All @@ -70,6 +76,7 @@ func createProxyPool(poolCfg common.ProxyPoolConfig) (*ProxyPool, error) {
return &ProxyPool{ID: poolCfg.ID}, fmt.Errorf("no proxy URLs defined for pool '%s'. at least one proxy URL is required", poolCfg.ID)
}

resolved := poolCfg.HTTPClientTimeouts.Resolve()
clients := make([]*http.Client, 0, len(poolCfg.Urls))

for _, proxyStr := range poolCfg.Urls {
Expand All @@ -82,14 +89,14 @@ func createProxyPool(poolCfg common.ProxyPoolConfig) (*ProxyPool, error) {
MaxIdleConns: 1024,
MaxIdleConnsPerHost: 256,
MaxConnsPerHost: 0, // Unlimited active connections (prevents bottleneck)
IdleConnTimeout: 90 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: resolved.IdleConnTimeout,
ResponseHeaderTimeout: resolved.ResponseHeaderTimeout,
TLSHandshakeTimeout: resolved.TLSHandshakeTimeout,
ExpectContinueTimeout: resolved.ExpectContinueTimeout,
Proxy: http.ProxyURL(proxyURL),
}
client := &http.Client{
Timeout: 60 * time.Second,
Timeout: resolved.Timeout,
Transport: transport,
}
clients = append(clients, client)
Expand Down
139 changes: 139 additions & 0 deletions common/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -786,13 +786,149 @@ func (c *RateLimitAutoTuneConfig) Copy() *RateLimitAutoTuneConfig {
return copied
}

// HTTPClientTimeouts contains HTTP client timeout configuration fields.
// These are shared between JsonRpcUpstreamConfig and ProxyPoolConfig.
type HTTPClientTimeouts struct {
// Timeout is the total time limit for a request including connection, headers, and body.
// Default: 60s
Timeout Duration `yaml:"timeout,omitempty" json:"timeout" tstype:"Duration"`

// ResponseHeaderTimeout specifies the time to wait for a server's response headers
// after fully writing the request. This does not include the time to read the response body.
// Default: 30s
ResponseHeaderTimeout Duration `yaml:"responseHeaderTimeout,omitempty" json:"responseHeaderTimeout" tstype:"Duration"`

// TLSHandshakeTimeout specifies the maximum time waiting for a TLS handshake.
// Default: 10s
TLSHandshakeTimeout Duration `yaml:"tlsHandshakeTimeout,omitempty" json:"tlsHandshakeTimeout" tstype:"Duration"`

// IdleConnTimeout is the maximum time an idle connection will remain idle before closing.
// Default: 90s
IdleConnTimeout Duration `yaml:"idleConnTimeout,omitempty" json:"idleConnTimeout" tstype:"Duration"`

// ExpectContinueTimeout specifies the time to wait for a server's first response headers
// after fully writing the request headers if the request has an "Expect: 100-continue" header.
// Default: 1s
ExpectContinueTimeout Duration `yaml:"expectContinueTimeout,omitempty" json:"expectContinueTimeout" tstype:"Duration"`
}

// Default timeout values for HTTP clients
const (
DefaultHTTPClientTimeout = 60 * time.Second
DefaultResponseHeaderTimeout = 30 * time.Second
DefaultTLSHandshakeTimeout = 10 * time.Second
DefaultIdleConnTimeout = 90 * time.Second
DefaultExpectContinueTimeout = 1 * time.Second
)

// ResolvedHTTPClientTimeouts contains the resolved timeout values with defaults applied.
type ResolvedHTTPClientTimeouts struct {
Timeout time.Duration
ResponseHeaderTimeout time.Duration
TLSHandshakeTimeout time.Duration
IdleConnTimeout time.Duration
ExpectContinueTimeout time.Duration
}

// Resolve applies default values to any unset timeout fields and returns resolved timeouts.
func (t *HTTPClientTimeouts) Resolve() ResolvedHTTPClientTimeouts {
if t == nil {
return ResolvedHTTPClientTimeouts{
Timeout: DefaultHTTPClientTimeout,
ResponseHeaderTimeout: DefaultResponseHeaderTimeout,
TLSHandshakeTimeout: DefaultTLSHandshakeTimeout,
IdleConnTimeout: DefaultIdleConnTimeout,
ExpectContinueTimeout: DefaultExpectContinueTimeout,
}
}
return ResolvedHTTPClientTimeouts{
Timeout: t.Timeout.WithDefault(DefaultHTTPClientTimeout),
ResponseHeaderTimeout: t.ResponseHeaderTimeout.WithDefault(DefaultResponseHeaderTimeout),
TLSHandshakeTimeout: t.TLSHandshakeTimeout.WithDefault(DefaultTLSHandshakeTimeout),
IdleConnTimeout: t.IdleConnTimeout.WithDefault(DefaultIdleConnTimeout),
ExpectContinueTimeout: t.ExpectContinueTimeout.WithDefault(DefaultExpectContinueTimeout),
}
}

// MergeFrom fills unset (zero value) timeout fields with values from defaults.
// This mutates the receiver in-place.
func (t *HTTPClientTimeouts) MergeFrom(defaults *HTTPClientTimeouts) {
if defaults == nil {
return
}
if t.Timeout == 0 && defaults.Timeout != 0 {
t.Timeout = defaults.Timeout
}
if t.ResponseHeaderTimeout == 0 && defaults.ResponseHeaderTimeout != 0 {
t.ResponseHeaderTimeout = defaults.ResponseHeaderTimeout
}
if t.TLSHandshakeTimeout == 0 && defaults.TLSHandshakeTimeout != 0 {
t.TLSHandshakeTimeout = defaults.TLSHandshakeTimeout
}
if t.IdleConnTimeout == 0 && defaults.IdleConnTimeout != 0 {
t.IdleConnTimeout = defaults.IdleConnTimeout
}
if t.ExpectContinueTimeout == 0 && defaults.ExpectContinueTimeout != 0 {
t.ExpectContinueTimeout = defaults.ExpectContinueTimeout
}
}

// Validate checks that all configured timeout values are valid (positive durations).
// Zero values are allowed as they indicate "use default".
// Also validates that sub-timeouts (responseHeaderTimeout, tlsHandshakeTimeout,
// expectContinueTimeout) do not exceed the total request timeout.
func (t *HTTPClientTimeouts) Validate(prefix string) error {
if t == nil {
return nil
}

// Validate individual timeout values - they must be positive if set
if t.Timeout != 0 && t.Timeout.Duration() <= 0 {
return fmt.Errorf("%stimeout must be a positive duration, got: %v", prefix, t.Timeout)
}
if t.ResponseHeaderTimeout != 0 && t.ResponseHeaderTimeout.Duration() <= 0 {
return fmt.Errorf("%sresponseHeaderTimeout must be a positive duration, got: %v", prefix, t.ResponseHeaderTimeout)
}
if t.TLSHandshakeTimeout != 0 && t.TLSHandshakeTimeout.Duration() <= 0 {
return fmt.Errorf("%stlsHandshakeTimeout must be a positive duration, got: %v", prefix, t.TLSHandshakeTimeout)
}
if t.IdleConnTimeout != 0 && t.IdleConnTimeout.Duration() <= 0 {
return fmt.Errorf("%sidleConnTimeout must be a positive duration, got: %v", prefix, t.IdleConnTimeout)
}
if t.ExpectContinueTimeout != 0 && t.ExpectContinueTimeout.Duration() <= 0 {
return fmt.Errorf("%sexpectContinueTimeout must be a positive duration, got: %v", prefix, t.ExpectContinueTimeout)
}

// Validate timeout relationships
if t.Timeout != 0 && t.ResponseHeaderTimeout != 0 &&
t.ResponseHeaderTimeout.Duration() > t.Timeout.Duration() {
return fmt.Errorf("%sresponseHeaderTimeout (%v) cannot exceed timeout (%v)",
prefix, t.ResponseHeaderTimeout, t.Timeout)
Comment on lines +900 to +903
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate header/handshake timeouts against default

The relationship checks only run when timeout is explicitly set, so configs like responseHeaderTimeout: 120s (or tlsHandshakeTimeout: 120s) with timeout omitted pass validation, but Resolve() later applies DefaultHTTPClientTimeout (60s). That means the request still times out at 60s even though the header/handshake timeout appears longer, which defeats the intent and yields surprising early timeouts. Consider validating these fields against the default timeout when timeout is zero, or adjusting resolution to enforce Timeout >= ResponseHeaderTimeout/TLSHandshakeTimeout after defaults are applied.

Useful? React with 👍 / 👎.

}
if t.Timeout != 0 && t.TLSHandshakeTimeout != 0 &&
t.TLSHandshakeTimeout.Duration() > t.Timeout.Duration() {
return fmt.Errorf("%stlsHandshakeTimeout (%v) cannot exceed timeout (%v)",
prefix, t.TLSHandshakeTimeout, t.Timeout)
}
if t.Timeout != 0 && t.ExpectContinueTimeout != 0 &&
t.ExpectContinueTimeout.Duration() > t.Timeout.Duration() {
return fmt.Errorf("%sexpectContinueTimeout (%v) cannot exceed timeout (%v)",
prefix, t.ExpectContinueTimeout, t.Timeout)
}

return nil
}

type JsonRpcUpstreamConfig struct {
SupportsBatch *bool `yaml:"supportsBatch,omitempty" json:"supportsBatch"`
BatchMaxSize int `yaml:"batchMaxSize,omitempty" json:"batchMaxSize"`
BatchMaxWait Duration `yaml:"batchMaxWait,omitempty" json:"batchMaxWait" tstype:"Duration"`
EnableGzip *bool `yaml:"enableGzip,omitempty" json:"enableGzip"`
Headers map[string]string `yaml:"headers,omitempty" json:"headers"`
ProxyPool string `yaml:"proxyPool,omitempty" json:"proxyPool"`

// HTTP client timeout settings (optional, with sensible defaults)
HTTPClientTimeouts `yaml:",inline" json:",inline"`
}

func (c *JsonRpcUpstreamConfig) Copy() *JsonRpcUpstreamConfig {
Expand Down Expand Up @@ -1385,6 +1521,9 @@ func (c *Config) HasRateLimiterBudget(id string) bool {
type ProxyPoolConfig struct {
ID string `yaml:"id" json:"id"`
Urls []string `yaml:"urls" json:"urls"`

// HTTP client timeout settings (optional, with sensible defaults)
HTTPClientTimeouts `yaml:",inline" json:",inline"`
}

type DeprecatedProjectHealthCheckConfig struct {
Expand Down
Loading
Loading