Skip to content
Merged
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
60 changes: 49 additions & 11 deletions evmrpc/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,19 @@ type Config struct {
// BatchResponseMaxSize is the maximum number of bytes returned from a
// batched JSON-RPC call (HTTP and WebSocket). Set to 0 to disable the limit.
BatchResponseMaxSize int `mapstructure:"batch_response_max_size"`

// MaxRequestBodyBytes is the maximum size, in bytes, of a single HTTP
// JSON-RPC request body. Requests larger than this are rejected (HTTP 413)
// before the body is buffered or JSON-decoded. 0 uses the go-ethereum
// default (5 MiB).
MaxRequestBodyBytes int64 `mapstructure:"max_request_body_bytes"`

// MaxConcurrentRequestBytes bounds the total size, in bytes, of HTTP
// JSON-RPC request bodies admitted for processing concurrently, weighted by
// each request's Content-Length. Requests that would exceed the budget are
// rejected fast (HTTP 429) before decode, capping peak memory under load.
// Set to 0 to disable the limit.
MaxConcurrentRequestBytes int64 `mapstructure:"max_concurrent_request_bytes"`
}

var DefaultConfig = Config{
Expand Down Expand Up @@ -216,17 +229,19 @@ var DefaultConfig = Config{
"sei_getEVMAddress",
"sei_getCosmosTx",
},
TraceBakeEnabled: false,
TraceBakeWorkers: 1,
TraceBakeQueueSize: 4096,
TraceBakeTracers: []string{"callTracer"},
TraceBakeWindowBlocks: 0,
TraceBakeUseSnapshot: false,
TraceBakeSnapshotWindow: 64,
IPRateLimitRPS: 200,
IPRateLimitBurst: 400,
BatchRequestLimit: 1000,
BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB
TraceBakeEnabled: false,
TraceBakeWorkers: 1,
TraceBakeQueueSize: 4096,
TraceBakeTracers: []string{"callTracer"},
TraceBakeWindowBlocks: 0,
TraceBakeUseSnapshot: false,
TraceBakeSnapshotWindow: 64,
IPRateLimitRPS: 200,
IPRateLimitBurst: 400,
BatchRequestLimit: 1000,
BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB
MaxRequestBodyBytes: 5 * 1024 * 1024, // 5 MiB (matches go-ethereum rpc default body limit)
MaxConcurrentRequestBytes: 128 * 1024 * 1024, // 128 MiB of request bodies admitted concurrently
}

const (
Expand Down Expand Up @@ -273,6 +288,8 @@ const (
flagIPRateLimitBurst = "evm.ip_rate_limit_burst"
flagBatchRequestLimit = "evm.batch_request_limit"
flagBatchResponseMaxSize = "evm.batch_response_max_size"
flagMaxRequestBodyBytes = "evm.max_request_body_bytes"
flagMaxConcurrentRequestBytes = "evm.max_concurrent_request_bytes"
)

func ReadConfig(opts servertypes.AppOptions) (Config, error) {
Expand Down Expand Up @@ -493,6 +510,16 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) {
return cfg, err
}
}
if v := opts.Get(flagMaxRequestBodyBytes); v != nil {
if cfg.MaxRequestBodyBytes, err = cast.ToInt64E(v); err != nil {
return cfg, err
}
}
if v := opts.Get(flagMaxConcurrentRequestBytes); v != nil {
if cfg.MaxConcurrentRequestBytes, err = cast.ToInt64E(v); err != nil {
return cfg, err
}
}
return cfg, nil
}

Expand Down Expand Up @@ -700,4 +727,15 @@ batch_request_limit = {{ .EVM.BatchRequestLimit }}
# batched JSON-RPC call (HTTP and WebSocket). Set to 0 to disable the limit.
batch_response_max_size = {{ .EVM.BatchResponseMaxSize }}

# max_request_body_bytes is the maximum size, in bytes, of a single HTTP
# JSON-RPC request body. Larger requests are rejected (HTTP 413) before the body
# is buffered or JSON-decoded. Set to 0 to use the default (5 MiB).
max_request_body_bytes = {{ .EVM.MaxRequestBodyBytes }}

# max_concurrent_request_bytes bounds the total size, in bytes, of HTTP JSON-RPC
# request bodies admitted for processing concurrently (weighted by each request's
# Content-Length). Requests that would exceed the budget are rejected fast
# (HTTP 429) before decode, capping peak memory under load. Set to 0 to disable.
max_concurrent_request_bytes = {{ .EVM.MaxConcurrentRequestBytes }}

`
27 changes: 27 additions & 0 deletions evmrpc/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type opts struct {
ipRateLimitBurst interface{}
batchRequestLimit interface{}
batchResponseMaxSize interface{}
maxRequestBodyBytes interface{}
maxConcurrentRequestBytes interface{}
}

func (o *opts) Get(k string) interface{} {
Expand Down Expand Up @@ -168,6 +170,12 @@ func (o *opts) Get(k string) interface{} {
if k == "evm.batch_response_max_size" {
return o.batchResponseMaxSize
}
if k == "evm.max_request_body_bytes" {
return o.maxRequestBodyBytes
}
if k == "evm.max_concurrent_request_bytes" {
return o.maxConcurrentRequestBytes
}
panic("unknown key")
}

Expand Down Expand Up @@ -210,6 +218,8 @@ func getDefaultOpts() opts {
400,
1000,
25 * 1000 * 1000,
int64(5 * 1024 * 1024),
int64(128 * 1024 * 1024),
}
}

Expand Down Expand Up @@ -423,6 +433,23 @@ func TestReadConfigBatchLimits(t *testing.T) {
require.Equal(t, 0, cfg.BatchResponseMaxSize)
}

func TestReadConfigRequestSizeLimits(t *testing.T) {
// Defaults flow through when not overridden.
cfg, err := config.ReadConfig(&opts{})
require.NoError(t, err)
require.Equal(t, config.DefaultConfig.MaxRequestBodyBytes, cfg.MaxRequestBodyBytes)
require.Equal(t, config.DefaultConfig.MaxConcurrentRequestBytes, cfg.MaxConcurrentRequestBytes)

// Custom values (including 0 to use default / disable) flow through.
o := getDefaultOpts()
o.maxRequestBodyBytes = int64(1024)
o.maxConcurrentRequestBytes = int64(0)
cfg, err = config.ReadConfig(&o)
require.NoError(t, err)
require.Equal(t, int64(1024), cfg.MaxRequestBodyBytes)
require.Equal(t, int64(0), cfg.MaxConcurrentRequestBytes)
}

func TestReadConfigEnableParallelizedBlockTrace(t *testing.T) {
opts := getDefaultOpts()
opts.enableParallelizedBlockTrace = true
Expand Down
32 changes: 27 additions & 5 deletions evmrpc/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ import (
)

const (
endpointKey = "endpoint"
connectionKey = "connection"
successKey = "success"
errorClassKey = "error_class"
jsonrpcCodeKey = "jsonrpc_code"
endpointKey = "endpoint"
connectionKey = "connection"
successKey = "success"
errorClassKey = "error_class"
jsonrpcCodeKey = "jsonrpc_code"
rejectReasonKey = "reason"
// reject reason values for requestRejectedCount.
rejectReasonOversize = "oversize" // body exceeded max_request_body_bytes
rejectReasonBusy = "busy" // max_concurrent_request_bytes budget exhausted
// error_class values; empty string ("") means success.
errorClassPanic = "panic"
errorClassExecutionReverted = "execution_reverted"
Expand Down Expand Up @@ -44,6 +48,7 @@ var (
wsConnectionCount metric.Int64Counter
redirectedRequestCount metric.Int64Counter
historicalDebugTraceAttemptCount metric.Int64Counter
requestRejectedCount metric.Int64Counter
}{
requestLatencySeconds: must(rpcTelemetryMeter.Float64Histogram(
"evmrpc_request_latency_seconds",
Expand All @@ -69,6 +74,11 @@ var (
metric.WithDescription("Number of debug_trace* requests targeting historical blocks"),
metric.WithUnit("{count}"),
)),
requestRejectedCount: must(rpcTelemetryMeter.Int64Counter(
"evmrpc_requests_rejected_total",
metric.WithDescription("Number of HTTP JSON-RPC requests rejected by pre-decode admission control (labeled by reason)"),
metric.WithUnit("{count}"),
)),
}
)

Expand Down Expand Up @@ -155,3 +165,15 @@ func recordHistoricalDebugTraceAttempt(ctx context.Context, endpoint, connection
),
)
}

// recordRequestRejected counts an HTTP JSON-RPC request dropped by pre-decode
// admission control. reason is one of rejectReasonOversize / rejectReasonBusy.
// No endpoint dimension is recorded: the rejection happens before the JSON-RPC
// method is decoded, so it is not yet known.
func recordRequestRejected(ctx context.Context, reason string) {
metrics.requestRejectedCount.Add(ctx, 1,
metric.WithAttributes(
attribute.String(rejectReasonKey, reason),
),
)
}
79 changes: 79 additions & 0 deletions evmrpc/request_limiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package evmrpc

import (
"net/http"

"golang.org/x/sync/semaphore"
)

// defaultMaxRequestBodyBytes matches github.com/ethereum/go-ethereum/rpc.defaultBodyLimit,
// the per-request HTTP body cap the inner JSON-RPC server applies. It is used when
// max_request_body_bytes is left at 0 ("use the default").
const defaultMaxRequestBodyBytes int64 = 5 * 1024 * 1024

// requestSizeLimiter is an HTTP middleware that bounds peak decode-time memory by
// admitting JSON-RPC requests *before* the body is buffered or decoded. It enforces:
//
// - maxBody: per-request body cap. Over-Content-Length requests get 413; the body is
// also wrapped in http.MaxBytesReader so chunked / mis-declared bodies can't exceed it.
// - budget: a size-weighted global semaphore bounding bytes admitted concurrently;
// over-budget requests get 429. The reservation is held for the full inner request
// (not just decode) and trusts the declared Content-Length — conservative by design,
// so slow or trickled requests can exhaust the budget even when little is buffered.
// Actual bytes stay hard-capped by MaxBytesReader; per-IP rate limiting will be
// wired in front of this handler.
type requestSizeLimiter struct {
inner http.Handler
maxBody int64 // always > 0 after construction
budget *semaphore.Weighted // nil when the global budget is disabled
}

// newRequestSizeLimiter wraps inner with pre-decode admission control. maxBody <= 0
// normalizes to defaultMaxRequestBodyBytes (the per-request cap is always applied —
// 0 means "use the default", never "no cap"). maxConcurrentBytes <= 0 disables the
// global budget. If a positive budget is smaller than maxBody it is raised to maxBody
// so that a single maximum-size request can always be admitted.
func newRequestSizeLimiter(inner http.Handler, maxBody, maxConcurrentBytes int64) http.Handler {
if maxBody <= 0 {
maxBody = defaultMaxRequestBodyBytes
}
l := &requestSizeLimiter{inner: inner, maxBody: maxBody}
if maxConcurrentBytes > 0 {
if maxConcurrentBytes < maxBody {
maxConcurrentBytes = maxBody
}
l.budget = semaphore.NewWeighted(maxConcurrentBytes)
}
return l
}

func (l *requestSizeLimiter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Per-request cap on the declared length (header-only, before any body read).
if r.ContentLength > l.maxBody {
recordRequestRejected(r.Context(), rejectReasonOversize)
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
// Backstop for chunked / mis-declared bodies: cap the bytes actually readable.
r.Body = http.MaxBytesReader(w, r.Body, l.maxBody)

if l.budget != nil {
// For requests with a known length Go's HTTP server enforces Content-Length,
// so it is a sound upper bound on bytes read (and 0 means an empty body that
// reserves nothing). Chunked / unknown-length requests report ContentLength
// == -1; reserve the worst case (maxBody) for them. weight is therefore always
// in [0, maxBody], and the budget is >= maxBody, so the request can always fit.
weight := r.ContentLength
if weight < 0 {
weight = l.maxBody
}
Comment thread
cursor[bot] marked this conversation as resolved.
if !l.budget.TryAcquire(weight) {
recordRequestRejected(r.Context(), rejectReasonBusy)
http.Error(w, "server busy", http.StatusTooManyRequests)
return
}
defer l.budget.Release(weight)
}

l.inner.ServeHTTP(w, r)
}
Loading
Loading