diff --git a/.config/forest.dic b/.config/forest.dic index 751957b10484..9783db4b8ba8 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -1,4 +1,4 @@ -270 +271 Algorand/M API's API/SM @@ -245,6 +245,7 @@ TTY UI unclutter uncompress +unparsable unrepresentable untrusted URL diff --git a/CHANGELOG.md b/CHANGELOG.md index 79620943c5c2..291dce0fc509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ ### Fixed +- [#6951](https://github.com/ChainSafe/forest/pull/6951): Set the HTTP response compression threshold for the RPC server to 1 KiB. Configurable via `FOREST_RPC_COMPRESS_MIN_BODY_SIZE`; set a negative value to disable compression entirely. Small JSON-RPC responses such as `eth_chainId` are no longer gzip-encoded, yielding a large throughput and latency improvement on high-QPS workloads. + ## Forest v0.33.0 "Patroclus" Non-mandatory release with a couple of larger internal changes, especially around mempool and garbage collection. It also includes support for the new finality resolution mechanism. Also, a couple of fixes! diff --git a/docs/docs/users/reference/env_variables.md b/docs/docs/users/reference/env_variables.md index e30f46ffb4eb..0d983f78e6b3 100644 --- a/docs/docs/users/reference/env_variables.md +++ b/docs/docs/users/reference/env_variables.md @@ -9,62 +9,63 @@ Besides CLI options and the configuration values in the configuration file, there are some environment variables that control the behavior of a `forest` process. -| Environment variable | Value | Default | Example | Description | -| --------------------------------------------------------- | ------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `FOREST_KEYSTORE_PHRASE` | any text | empty | `asfvdda` | The passphrase for the encrypted keystore | -| `FOREST_CAR_LOADER_FILE_IO` | 1 or true | false | true | Load CAR files with `RandomAccessFile` instead of `Mmap` | -| `FOREST_DB_DEV_MODE` | [see here](#forest_db_dev_mode) | current | current | The database to use in development mode | -| `FOREST_ACTOR_BUNDLE_PATH` | file path | empty | `/path/to/file.car.zst` | Path to the local actor bundle, download from remote servers when not set | -| `FIL_PROOFS_PARAMETER_CACHE` | directory path | empty | `/var/tmp/filecoin-proof-parameters` | Path to folder that caches fil proof parameter files | -| `FOREST_PROOFS_ONLY_IPFS_GATEWAY` | 1 or true | false | 1 | Use only IPFS gateway for proofs parameters download | -| `FOREST_FORCE_TRUST_PARAMS` | 1 or true | false | 1 | Trust the parameters downloaded from the Cloudflare/IPFS | -| `IPFS_GATEWAY` | URL | `https://proofs.filecoin.io/ipfs/` | `https://proofs.filecoin.io/ipfs/` | The IPFS gateway to use for downloading proofs parameters | -| `FOREST_RPC_DEFAULT_TIMEOUT` | Duration (in seconds) | 60 | 10 | The default timeout for RPC calls | -| `FOREST_RPC_MAX_CONNECTIONS` | positive integer | 1000 | 42 | Maximum number of allowed connections for the RPC server | -| `FOREST_MAX_CONCURRENT_REQUEST_RESPONSE_STREAMS_PER_PEER` | positive integer | 10 | 10 | the maximum concurrent streams per peer for request-response-based p2p protocols | -| `FOREST_BLOCK_DELAY_SECS` | positive integer | Depends on the network | 30 | Duration of each tipset epoch | -| `FOREST_PROPAGATION_DELAY_SECS` | positive integer | Depends on the network | 20 | How long to wait for a block to propagate through the network | -| `FOREST_PLEDGE_RULE_RAMP` | positive integer | Depends on the network | 200 | Pledge rule ramp duration in epochs (FIP 0081) | -| `FOREST_MAX_FILTERS` | integer | 100 | 100 | The maximum number of filters | -| `FOREST_MAX_FILTER_RESULTS` | positive integer | 10,000 | 10000 | The maximum number of filter results | -| `FOREST_MAX_FILTER_HEIGHT_RANGE` | positive integer | 2880 | 2880 | The maximum filter height range allowed, a conservative limit of one day | -| `FOREST_STATE_MIGRATION_THREADS` | integer | Depends on the machine. | 3 | The number of threads for state migration thread-pool. Advanced users only. | -| `FOREST_CONFIG_PATH` | string | /$FOREST_HOME/com.ChainSafe.Forest/config.toml | `/path/to/config.toml` | Forest configuration path. Alternatively supplied via `--config` cli parameter. | -| `FOREST_TEST_RNG_FIXED_SEED` | non-negative integer | empty | 0 | Override RNG with a reproducible one seeded by the value. This should never be used out of test context for security. | -| `RUST_LOG` | string | empty | `debug,forest_libp2p::service=info` | Allows for log level customization. | -| `FOREST_IGNORE_DRAND` | 1 or true | empty | 1 | Ignore Drand validation. | -| `FOREST_LIBP2P_METRICS_ENABLED` | 1 or true | empty | 1 | Include `libp2p` metrics in Forest's Prometheus output. | -| `FOREST_F3_SIDECAR_RPC_ENDPOINT` | string | 127.0.0.1:23456 | `127.0.0.1:23456` | An RPC endpoint of F3 sidecar. | -| `FOREST_F3_SIDECAR_FFI_ENABLED` | 1 or true | hard-coded per chain | 1 | Whether or not to start the F3 sidecar via FFI | -| `FOREST_F3_CONSENSUS_ENABLED` | 1 or true | hard-coded per chain | 1 | Whether or not to apply the F3 consensus to the node | -| `FOREST_F3_FINALITY` | integer | inherited from chain configuration | 900 | Set the chain finality epochs in F3 manifest | -| `FOREST_F3_PERMANENT_PARTICIPATING_MINER_ADDRESSES` | comma delimited strings | empty | `t0100,t0101` | Set the miner addresses that participate in F3 permanently | -| `FOREST_F3_INITIAL_POWER_TABLE` | string | empty | `bafyreicmaj5hhoy5mgqvamfhgexxyergw7hdeshizghodwkjg6qmpoco7i` | Set the F3 initial power table CID | -| `FOREST_F3_ROOT` | string | [FOREST_DATA_ROOT]/f3 | `/var/tmp/f3` | Set the data directory for F3 | -| `FOREST_F3_BOOTSTRAP_EPOCH` | integer | -1 | 100 | Set the bootstrap epoch for F3 | -| `FOREST_DRAND_MAINNET_CONFIG` | string | empty | refer to Drand config format section | Override `DRAND_MAINNET` config | -| `FOREST_DRAND_QUICKNET_CONFIG` | string | empty | refer to Drand config format section | Override `DRAND_QUICKNET` config | -| `FOREST_DRAND_INCENTINET_CONFIG` | string | empty | refer to Drand config format section | Override `DRAND_INCENTINET` config | -| `FOREST_TRACE_FILTER_MAX_RESULT` | positive integer | 500 | 1000 | Sets the maximum results returned per request by `trace_filter` | -| `FOREST_CHAIN_INDEXER_ENABLED` | 1 or true | false | 1 | Whether or not to index the chain to support the Ethereum RPC API | -| `FOREST_MESSAGES_IN_TIPSET_CACHE_SIZE` | positive integer | 100 | 42 | The size of an internal cache of tipsets to messages | -| `FOREST_STATE_MIGRATION_DB_WRITE_BUFFER` | non-negative integer | 10000 | 100000 | The size of db write buffer for state migration (`~10MB` RAM per `10k` buffer) | -| `FOREST_SNAPSHOT_GC_INTERVAL_EPOCHS` | non-negative integer | 20160 | 8000 | The interval in epochs for scheduling snapshot GC | -| `FOREST_SNAPSHOT_GC_CHECK_INTERVAL_SECONDS` | non-negative integer | 300 | 60 | The interval in seconds for checking if snapshot GC should run | -| `FOREST_SNAPSHOT_GC_KEEP_STATE_TREE_EPOCHS` | non-negative integer | 2000 | 20160 | The number of most recent epochs of state trees to keep after GC | -| `FOREST_DISABLE_BAD_BLOCK_CACHE` | 1 or true | empty | 1 | Whether or not to disable bad block cache | -| `FOREST_ZSTD_FRAME_CACHE_DEFAULT_MAX_SIZE` | positive integer | 268435456 | 536870912 | The default zstd frame cache max size in bytes | -| `FOREST_JWT_DISABLE_EXP_VALIDATION` | 1 or true | empty | 1 | Whether or not to disable JWT expiration validation | -| `FOREST_ETH_BLOCK_CACHE_SIZE` | positive integer | 500 | 1 | The size of Eth block cache | -| `FOREST_RPC_BACKFILL_FULL_TIPSET_FROM_NETWORK` | 1 or true | false | 1 | Whether or not to backfill full tipsets from the p2p network | -| `FOREST_STRICT_JSON` | 1 or true | false | 1 | Enable strict JSON validation to detect duplicate keys in RPC requests | -| `FOREST_AUTO_DOWNLOAD_SNAPSHOT_PATH` | URL or file path | empty | `/var/tmp/forest_snapshot_calibnet.forest.car.zst` | Override snapshot path for `--auto-download-snapshot` | -| `FOREST_DOWNLOAD_CONNECTIONS` | positive integer | 5 | 10 | Number of parallel HTTP connections for downloading snapshots | -| `FOREST_ETH_V1_DISABLE_F3_FINALITY_RESOLUTION` | 1 or true | empty | 1 | Whether or not to disable F3 finality resolution in Eth `v1` RPC methods | -| `FOREST_GENESIS_NETWORK_VERSION` | non-negative integer | empty | 25 | Override the genesis network version (devnet only) | -| `FOREST_TIPSET_CACHE_DISABLED` | 1 or true | empty | 1 | Disable the tipset cache. Used internally by development and tool subcommands | -| `FOREST_MAX_CONCURRENT_CHAIN_EXCHANGE_REQUESTS` | positive integer | 3 | 3 | number of max concurrent requests to send over chain exchange protocol | -| `FOREST_FEES_FIP0115HEIGHT` | integer | -1 | 100 | FIP-0115 base fee activation epoch. Set to -1 to disable. **Consensus-breaking, for testing only.** | +| Environment variable | Value | Default | Example | Description | +| --------------------------------------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `FOREST_KEYSTORE_PHRASE` | any text | empty | `asfvdda` | The passphrase for the encrypted keystore | +| `FOREST_CAR_LOADER_FILE_IO` | 1 or true | false | true | Load CAR files with `RandomAccessFile` instead of `Mmap` | +| `FOREST_DB_DEV_MODE` | [see here](#forest_db_dev_mode) | current | current | The database to use in development mode | +| `FOREST_ACTOR_BUNDLE_PATH` | file path | empty | `/path/to/file.car.zst` | Path to the local actor bundle, download from remote servers when not set | +| `FIL_PROOFS_PARAMETER_CACHE` | directory path | empty | `/var/tmp/filecoin-proof-parameters` | Path to folder that caches fil proof parameter files | +| `FOREST_PROOFS_ONLY_IPFS_GATEWAY` | 1 or true | false | 1 | Use only IPFS gateway for proofs parameters download | +| `FOREST_FORCE_TRUST_PARAMS` | 1 or true | false | 1 | Trust the parameters downloaded from the Cloudflare/IPFS | +| `IPFS_GATEWAY` | URL | `https://proofs.filecoin.io/ipfs/` | `https://proofs.filecoin.io/ipfs/` | The IPFS gateway to use for downloading proofs parameters | +| `FOREST_RPC_DEFAULT_TIMEOUT` | Duration (in seconds) | 60 | 10 | The default timeout for RPC calls | +| `FOREST_RPC_MAX_CONNECTIONS` | positive integer | 1000 | 42 | Maximum number of allowed connections for the RPC server | +| `FOREST_RPC_COMPRESS_MIN_BODY_SIZE` | integer in `[-1, 65535]` (bytes) | 1024 | 2048 (or `-1` to disable) | Minimum response body size for which HTTP compression (gzip) is applied; smaller responses are sent uncompressed. Values above 65535 are clamped to 65535. Set to a negative value (e.g. `-1`) to disable compression entirely | +| `FOREST_MAX_CONCURRENT_REQUEST_RESPONSE_STREAMS_PER_PEER` | positive integer | 10 | 10 | the maximum concurrent streams per peer for request-response-based p2p protocols | +| `FOREST_BLOCK_DELAY_SECS` | positive integer | Depends on the network | 30 | Duration of each tipset epoch | +| `FOREST_PROPAGATION_DELAY_SECS` | positive integer | Depends on the network | 20 | How long to wait for a block to propagate through the network | +| `FOREST_PLEDGE_RULE_RAMP` | positive integer | Depends on the network | 200 | Pledge rule ramp duration in epochs (FIP 0081) | +| `FOREST_MAX_FILTERS` | integer | 100 | 100 | The maximum number of filters | +| `FOREST_MAX_FILTER_RESULTS` | positive integer | 10,000 | 10000 | The maximum number of filter results | +| `FOREST_MAX_FILTER_HEIGHT_RANGE` | positive integer | 2880 | 2880 | The maximum filter height range allowed, a conservative limit of one day | +| `FOREST_STATE_MIGRATION_THREADS` | integer | Depends on the machine. | 3 | The number of threads for state migration thread-pool. Advanced users only. | +| `FOREST_CONFIG_PATH` | string | /$FOREST_HOME/com.ChainSafe.Forest/config.toml | `/path/to/config.toml` | Forest configuration path. Alternatively supplied via `--config` cli parameter. | +| `FOREST_TEST_RNG_FIXED_SEED` | non-negative integer | empty | 0 | Override RNG with a reproducible one seeded by the value. This should never be used out of test context for security. | +| `RUST_LOG` | string | empty | `debug,forest_libp2p::service=info` | Allows for log level customization. | +| `FOREST_IGNORE_DRAND` | 1 or true | empty | 1 | Ignore Drand validation. | +| `FOREST_LIBP2P_METRICS_ENABLED` | 1 or true | empty | 1 | Include `libp2p` metrics in Forest's Prometheus output. | +| `FOREST_F3_SIDECAR_RPC_ENDPOINT` | string | 127.0.0.1:23456 | `127.0.0.1:23456` | An RPC endpoint of F3 sidecar. | +| `FOREST_F3_SIDECAR_FFI_ENABLED` | 1 or true | hard-coded per chain | 1 | Whether or not to start the F3 sidecar via FFI | +| `FOREST_F3_CONSENSUS_ENABLED` | 1 or true | hard-coded per chain | 1 | Whether or not to apply the F3 consensus to the node | +| `FOREST_F3_FINALITY` | integer | inherited from chain configuration | 900 | Set the chain finality epochs in F3 manifest | +| `FOREST_F3_PERMANENT_PARTICIPATING_MINER_ADDRESSES` | comma delimited strings | empty | `t0100,t0101` | Set the miner addresses that participate in F3 permanently | +| `FOREST_F3_INITIAL_POWER_TABLE` | string | empty | `bafyreicmaj5hhoy5mgqvamfhgexxyergw7hdeshizghodwkjg6qmpoco7i` | Set the F3 initial power table CID | +| `FOREST_F3_ROOT` | string | [FOREST_DATA_ROOT]/f3 | `/var/tmp/f3` | Set the data directory for F3 | +| `FOREST_F3_BOOTSTRAP_EPOCH` | integer | -1 | 100 | Set the bootstrap epoch for F3 | +| `FOREST_DRAND_MAINNET_CONFIG` | string | empty | refer to Drand config format section | Override `DRAND_MAINNET` config | +| `FOREST_DRAND_QUICKNET_CONFIG` | string | empty | refer to Drand config format section | Override `DRAND_QUICKNET` config | +| `FOREST_DRAND_INCENTINET_CONFIG` | string | empty | refer to Drand config format section | Override `DRAND_INCENTINET` config | +| `FOREST_TRACE_FILTER_MAX_RESULT` | positive integer | 500 | 1000 | Sets the maximum results returned per request by `trace_filter` | +| `FOREST_CHAIN_INDEXER_ENABLED` | 1 or true | false | 1 | Whether or not to index the chain to support the Ethereum RPC API | +| `FOREST_MESSAGES_IN_TIPSET_CACHE_SIZE` | positive integer | 100 | 42 | The size of an internal cache of tipsets to messages | +| `FOREST_STATE_MIGRATION_DB_WRITE_BUFFER` | non-negative integer | 10000 | 100000 | The size of db write buffer for state migration (`~10MB` RAM per `10k` buffer) | +| `FOREST_SNAPSHOT_GC_INTERVAL_EPOCHS` | non-negative integer | 20160 | 8000 | The interval in epochs for scheduling snapshot GC | +| `FOREST_SNAPSHOT_GC_CHECK_INTERVAL_SECONDS` | non-negative integer | 300 | 60 | The interval in seconds for checking if snapshot GC should run | +| `FOREST_SNAPSHOT_GC_KEEP_STATE_TREE_EPOCHS` | non-negative integer | 2000 | 20160 | The number of most recent epochs of state trees to keep after GC | +| `FOREST_DISABLE_BAD_BLOCK_CACHE` | 1 or true | empty | 1 | Whether or not to disable bad block cache | +| `FOREST_ZSTD_FRAME_CACHE_DEFAULT_MAX_SIZE` | positive integer | 268435456 | 536870912 | The default zstd frame cache max size in bytes | +| `FOREST_JWT_DISABLE_EXP_VALIDATION` | 1 or true | empty | 1 | Whether or not to disable JWT expiration validation | +| `FOREST_ETH_BLOCK_CACHE_SIZE` | positive integer | 500 | 1 | The size of Eth block cache | +| `FOREST_RPC_BACKFILL_FULL_TIPSET_FROM_NETWORK` | 1 or true | false | 1 | Whether or not to backfill full tipsets from the p2p network | +| `FOREST_STRICT_JSON` | 1 or true | false | 1 | Enable strict JSON validation to detect duplicate keys in RPC requests | +| `FOREST_AUTO_DOWNLOAD_SNAPSHOT_PATH` | URL or file path | empty | `/var/tmp/forest_snapshot_calibnet.forest.car.zst` | Override snapshot path for `--auto-download-snapshot` | +| `FOREST_DOWNLOAD_CONNECTIONS` | positive integer | 5 | 10 | Number of parallel HTTP connections for downloading snapshots | +| `FOREST_ETH_V1_DISABLE_F3_FINALITY_RESOLUTION` | 1 or true | empty | 1 | Whether or not to disable F3 finality resolution in Eth `v1` RPC methods | +| `FOREST_GENESIS_NETWORK_VERSION` | non-negative integer | empty | 25 | Override the genesis network version (devnet only) | +| `FOREST_TIPSET_CACHE_DISABLED` | 1 or true | empty | 1 | Disable the tipset cache. Used internally by development and tool subcommands | +| `FOREST_MAX_CONCURRENT_CHAIN_EXCHANGE_REQUESTS` | positive integer | 3 | 3 | number of max concurrent requests to send over chain exchange protocol | +| `FOREST_FEES_FIP0115HEIGHT` | integer | -1 | 100 | FIP-0115 base fee activation epoch. Set to -1 to disable. **Consensus-breaking, for testing only.** | ### `FOREST_F3_SIDECAR_FFI_BUILD_OPT_OUT` diff --git a/src/rpc/compression_layer.rs b/src/rpc/compression_layer.rs new file mode 100644 index 000000000000..c4aa0ff30318 --- /dev/null +++ b/src/rpc/compression_layer.rs @@ -0,0 +1,256 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! HTTP response compression middleware that normalizes the response body +//! back to `jsonrpsee`'s [`HttpBody`] so the layer can be conditionally +//! installed via [`tower::util::option_layer`]. +//! +//! Ported from `reth_rpc_layer::compression_layer`. + +use std::{ + env, + future::Future, + pin::Pin, + sync::LazyLock, + task::{Context, Poll}, +}; + +use jsonrpsee::server::{HttpBody, HttpRequest, HttpResponse}; +use tower::{Layer, Service}; +use tower_http::compression::predicate::SizeAbove; +use tower_http::compression::{Compression, CompressionLayer as TowerCompressionLayer}; + +const COMPRESS_MIN_BODY_SIZE_VAR: &str = "FOREST_RPC_COMPRESS_MIN_BODY_SIZE"; + +/// RPC response compression policy, read from [`COMPRESS_MIN_BODY_SIZE_VAR`]. +/// +/// `None` means no [`CompressionLayer`] is installed at all; `Some(bytes)` +/// means install a layer that compresses responses whose body is at least +/// `bytes`. +/// +/// - Any value in `0..=u16::MAX` sets the minimum response size that will +/// be gzip-encoded; smaller responses are sent as-is. Values above +/// `u16::MAX` are clamped because `SizeAbove` is backed by a `u16`. +/// - Any negative integer (e.g. `-1`) disables compression entirely. +/// - Unset defaults to 1 KiB. +pub(crate) static COMPRESS_MIN_BODY_SIZE: LazyLock> = LazyLock::new(|| { + parse_compress_min_body_size(env::var(COMPRESS_MIN_BODY_SIZE_VAR).ok().as_deref()) +}); + +/// Interpret a [`COMPRESS_MIN_BODY_SIZE_VAR`] value. +/// +/// Returns `None` to signal "compression disabled", `Some(bytes)` for the +/// minimum response size above which compression should be applied. +/// Unset and unparsable values fall back to the 1 KiB default. +/// Values above `u16::MAX` are clamped to `u16::MAX`. +fn parse_compress_min_body_size(raw: Option<&str>) -> Option { + // Seems like a sane default, e.g., `erpc` uses 1024 bytes as well. + // + const DEFAULT: u16 = 1024; + let Some(raw) = raw else { + return Some(DEFAULT); + }; + // Parse as i128 so any realistically-typable integer lands in one of the + // defined branches (negative → None, too-large → clamp) rather than + // silently falling back to DEFAULT just because it didn't fit in i32. + let Ok(parsed) = raw.parse::() else { + tracing::warn!( + "{COMPRESS_MIN_BODY_SIZE_VAR}={raw:?} is not a valid integer; \ + falling back to default ({DEFAULT} bytes)" + ); + return Some(DEFAULT); + }; + if parsed < 0 { + return None; + } + let max = i128::from(u16::MAX); + if parsed > max { + tracing::warn!( + "{COMPRESS_MIN_BODY_SIZE_VAR}={parsed} exceeds the maximum of {max}; \ + clamping to {max} bytes" + ); + } + // The prior branches bound `parsed.min(max)` to `[0, u16::MAX]`. + Some(u16::try_from(parsed.min(max)).expect("bounded above to u16::MAX")) +} + +/// Compresses responses with a body above `min_body_size` bytes. +#[derive(Clone)] +pub(crate) struct CompressionLayer { + inner: TowerCompressionLayer, +} + +impl CompressionLayer { + /// Compress responses whose body is at least `min_body_size` bytes. + pub(crate) fn new(min_body_size: u16) -> Self { + Self { + inner: TowerCompressionLayer::new().compress_when(SizeAbove::new(min_body_size)), + } + } +} + +impl Layer for CompressionLayer { + type Service = CompressionService; + + fn layer(&self, inner: S) -> Self::Service { + CompressionService { + inner: self.inner.layer(inner), + } + } +} + +#[derive(Clone)] +pub(crate) struct CompressionService { + inner: Compression, +} + +impl Service> for CompressionService +where + S: Service, Response = HttpResponse>, + S::Future: Send + 'static, +{ + type Response = HttpResponse; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: HttpRequest) -> Self::Future { + let fut = self.inner.call(req); + Box::pin(async move { + // Re-box to match `Identity`'s response body type (see module doc). + let resp = fut.await?; + let (parts, compressed_body) = resp.into_parts(); + Ok(Self::Response::from_parts( + parts, + HttpBody::new(compressed_body), + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::header::{ACCEPT_ENCODING, CONTENT_ENCODING}; + use std::{convert::Infallible, future::ready}; + + const TEST_DATA: &str = "cthulhu fhtagn "; + const REPEAT_COUNT: usize = 1000; + + #[derive(Clone)] + struct MockService; + + impl Service for MockService { + type Response = HttpResponse; + type Error = Infallible; + type Future = std::future::Ready>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _: HttpRequest) -> Self::Future { + let body = HttpBody::from(TEST_DATA.repeat(REPEAT_COUNT)); + ready(Ok(HttpResponse::builder().body(body).unwrap())) + } + } + + async fn body_size(resp: HttpResponse) -> usize { + let body = axum::body::Body::new(resp.into_body()); + axum::body::to_bytes(body, usize::MAX).await.unwrap().len() + } + + fn uncompressed_size() -> usize { + TEST_DATA.repeat(REPEAT_COUNT).len() + } + + #[tokio::test] + async fn gzip_compresses_when_requested() { + let mut svc = CompressionLayer::new(0).layer(MockService); + let req = HttpRequest::builder() + .header(ACCEPT_ENCODING, "gzip") + .body(HttpBody::empty()) + .unwrap(); + let resp = svc.call(req).await.unwrap(); + assert_eq!(resp.headers().get(CONTENT_ENCODING).unwrap(), "gzip"); + assert!(body_size(resp).await < uncompressed_size()); + } + + #[tokio::test] + async fn passthrough_when_encoding_not_requested() { + let mut svc = CompressionLayer::new(0).layer(MockService); + let req = HttpRequest::builder().body(HttpBody::empty()).unwrap(); + let resp = svc.call(req).await.unwrap(); + assert!(resp.headers().get(CONTENT_ENCODING).is_none()); + assert_eq!(body_size(resp).await, uncompressed_size()); + } + + #[tokio::test] + async fn below_threshold_is_not_compressed() { + let mut svc = CompressionLayer::new(u16::MAX).layer(MockService); + let req = HttpRequest::builder() + .header(ACCEPT_ENCODING, "gzip") + .body(HttpBody::empty()) + .unwrap(); + let resp = svc.call(req).await.unwrap(); + assert!(resp.headers().get(CONTENT_ENCODING).is_none()); + assert_eq!(body_size(resp).await, uncompressed_size()); + } + + #[test] + fn parse_defaults_when_unset() { + assert_eq!(parse_compress_min_body_size(None), Some(1024)); + } + + #[test] + fn parse_negative_disables() { + assert_eq!(parse_compress_min_body_size(Some("-1")), None); + assert_eq!(parse_compress_min_body_size(Some("-999999")), None); + assert_eq!(parse_compress_min_body_size(Some("-2147483648")), None); // i32::MIN + // Values below i32::MIN must still disable rather than fall back. + assert_eq!( + parse_compress_min_body_size(Some("-9223372036854775808")), + None + ); // i64::MIN + } + + #[test] + fn parse_accepts_in_range_values() { + assert_eq!(parse_compress_min_body_size(Some("0")), Some(0)); + assert_eq!(parse_compress_min_body_size(Some("512")), Some(512)); + assert_eq!(parse_compress_min_body_size(Some("1024")), Some(1024)); + assert_eq!(parse_compress_min_body_size(Some("65535")), Some(u16::MAX)); + } + + #[test] + fn parse_clamps_above_u16_max() { + assert_eq!(parse_compress_min_body_size(Some("65536")), Some(u16::MAX)); + assert_eq!( + parse_compress_min_body_size(Some("1000000")), + Some(u16::MAX) + ); + assert_eq!( + parse_compress_min_body_size(Some("2147483647")), // i32::MAX + Some(u16::MAX) + ); + // Values above i32::MAX must still clamp rather than fall back. + assert_eq!( + parse_compress_min_body_size(Some("99999999999")), + Some(u16::MAX) + ); + assert_eq!( + parse_compress_min_body_size(Some("9223372036854775807")), // i64::MAX + Some(u16::MAX) + ); + } + + #[test] + fn parse_invalid_falls_back_to_default() { + assert_eq!(parse_compress_min_body_size(Some("")), Some(1024)); + assert_eq!(parse_compress_min_body_size(Some("lots")), Some(1024)); + assert_eq!(parse_compress_min_body_size(Some("1.5")), Some(1024)); + } +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 1eaff397b9cb..1d56d72c5310 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -5,6 +5,7 @@ use crate::rpc::methods::eth::pubsub_trait::EthPubSubApiServer; mod auth_layer; mod channel; mod client; +mod compression_layer; mod filter_layer; mod filter_list; pub mod json_validator; @@ -319,9 +320,9 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::misc::GetActorEventsRaw); }; } +use compression_layer::{COMPRESS_MIN_BODY_SIZE, CompressionLayer}; pub(crate) use for_each_rpc_method; use sync::SnapshotProgressTracker; -use tower_http::compression::CompressionLayer; use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer; #[allow(unused)] @@ -573,7 +574,7 @@ where ) .set_http_middleware( tower::ServiceBuilder::new() - .layer(CompressionLayer::new()) + .option_layer(COMPRESS_MIN_BODY_SIZE.map(CompressionLayer::new)) // Mark the `Authorization` request header as sensitive so it doesn't show in logs .layer(SetSensitiveRequestHeadersLayer::new(std::iter::once( http::header::AUTHORIZATION,