Limit decompressed body size

Bound the size of decompressed ingest request bodies to prevent
decompression-bomb OOMs. Only the on-the-wire size was capped before, so a
small, highly compressible payload (e.g. a few hundred KiB of zstd) could
inflate to many gigabytes and exhaust the node's memory.

- Add `ingest_api.max_decompressed_body_size` (default 256 MiB), enforced
  *as the body inflates* (`take(limit + 1)`) so an oversized payload is
  never fully decompressed -- bounding both memory and CPU.
- Apply it to the Elasticsearch `_bulk`, OTLP, and native ingest paths.
  OTLP REST is now size-bounded (it was previously unbounded).
- Reject offenders with HTTP 413, a per-endpoint
  `decompression_too_large_total` counter (labeled by endpoint), and a
  rate-limited warning.
- Rename `ingest_api.content_length_limit` to `ingest_api.max_body_size`
  (the wire / pre-decompression cap); the former name is still accepted as
  a config alias.
This commit is contained in:
Adrien Guillo committed 2026-10-21 16:26:23 -04:00
1 parent 3348f4dc98
commit f0cca2c356
12 files changed
+401 -79

No files matched your search

+4
View File
@@ -14,12 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- (Jaeger) Query resource attributes when Jaeger request carries tags
### Changed
- **Breaking**: ingest requests whose body decompresses to more than `ingest_api.max_decompressed_body_size` (default 256 MiB) are now rejected with HTTP 413 `Payload Too Large`. Previously only the *compressed* request size was bounded, so a small highly-compressible payload could inflate to many gigabytes. Affects the Elasticsearch `_bulk`, OpenTelemetry (OTLP), and native ingest endpoints. Split large batches into smaller requests, or raise the limit.
- OTLP REST endpoints now enforce a request-size limit (previously unbounded), set to `ingest_api.max_decompressed_body_size` (default 256 MiB).
- Renamed `ingest_api.content_length_limit` to `ingest_api.max_body_size`; the former name is still accepted as a config alias.
### Deprecated
### Removed
### Security
- Bound the size of decompressed ingest request bodies (`ingest_api.max_decompressed_body_size`, default 256 MiB) to prevent decompression-bomb out-of-memory crashes. The limit is enforced as the body is inflated, so an oversized payload is never fully decompressed.
--->
+4 -2
View File
@@ -266,7 +266,8 @@ indexer:
| --- | --- | --- |
| `max_queue_memory_usage` | Maximum size in bytes of the in-memory Ingest queue. | `2GiB` |
| `max_queue_disk_usage` | Maximum disk-space in bytes taken by the Ingest queue. The minimum size is at least `256M` and be at least `max_queue_memory_usage`. | `4GiB` |
| `content_length_limit` | Maximum payload size uncompressed. Increasing this is discouraged, use a [file source](../ingest-data/sqs-files.md) instead. | `10MiB` |
| `max_body_size` | Maximum size of a request body as received on the wire (i.e. *before* decompression). Increasing this is discouraged, use a [file source](../ingest-data/sqs-files.md) instead. Formerly named `content_length_limit`, which is still accepted as an alias. | `10MiB` |
| `max_decompressed_body_size` | Maximum size of a request body *after* decompression. A request is rejected with HTTP 413 `Payload Too Large` if its body decompresses beyond this; the limit is enforced as the body inflates, bounding memory and CPU and preventing decompression-bomb out-of-memory crashes. Must be at least `max_body_size`. | `256MiB` |
| `grpc_compression_algorithm` | Compression algorithm (`gzip` or `zstd`) to use for gRPC traffic between nodes for the ingest service | `None` |
| `decommission_timeout` | Maximum amount of time to wait for the ingester to finish decommissioning gracefully on shutdown before giving up. Can be overridden with the `QW_INGEST_DECOMMISSION_TIMEOUT` environment variable. | `300s` |
@@ -276,7 +277,8 @@ Example:
ingest_api:
max_queue_memory_usage: 2GiB
max_queue_disk_usage: 4GiB
content_length_limit: 10MiB
max_body_size: 10MiB
max_decompressed_body_size: 256MiB
grpc_compression_algorithm: zstd
decommission_timeout: 300s
```
@@ -717,7 +717,14 @@ pub struct IngestApiConfig {
/// Deprecated: ingest replication is no longer supported.
#[serde(default, rename = "replication_factor", skip_serializing)]
_replication_factor: Option<serde::de::IgnoredAny>,
pub content_length_limit: ByteSize,
/// Maximum size of a request body as received on the wire, i.e. *before* decompression.
/// Accepts the former name `content_length_limit` for backward compatibility.
#[serde(alias = "content_length_limit")]
pub max_body_size: ByteSize,
/// Maximum size of a request body *after* decompression. This bounds the memory (and CPU) a
/// single compressed request can consume while being inflated, preventing decompression-bomb
/// OOMs where a small compressed body expands to many gigabytes.
pub max_decompressed_body_size: ByteSize,
/// (hidden) Targeted throughput for each shard
pub shard_throughput_limit: ByteSize,
/// (hidden) Maximum accumulated throughput capacity for underutilized
@@ -742,7 +749,8 @@ impl Default for IngestApiConfig {
max_queue_memory_usage: ByteSize::gib(2),
max_queue_disk_usage: ByteSize::gib(4),
_replication_factor: None,
content_length_limit: ByteSize::mib(10),
max_body_size: ByteSize::mib(10),
max_decompressed_body_size: ByteSize::mib(256),
shard_throughput_limit: DEFAULT_SHARD_THROUGHPUT_LIMIT,
shard_burst_limit: DEFAULT_SHARD_BURST_LIMIT,
shard_scale_up_factor: DEFAULT_SHARD_SCALE_UP_FACTOR,
@@ -809,12 +817,21 @@ impl IngestApiConfig {
"shard_throughput_limit ({}) must be within 1mb and 20mb",
self.shard_throughput_limit.display().si()
);
// A decompressed body is always at least as large as its compressed form, so a decompressed
// cap below the wire body-size limit would reject bodies that the body-size filter already
// accepted.
ensure!(
self.max_decompressed_body_size >= self.max_body_size,
"max_decompressed_body_size ({}) must be at least max_body_size ({})",
self.max_decompressed_body_size.display().si(),
self.max_body_size.display().si()
);
// The newline delimited format is persisted as something a bit larger
// (lines prefixed with their length)
let estimated_persist_size = ByteSize::b(3 * self.content_length_limit.as_u64() / 2);
let estimated_persist_size = ByteSize::b(3 * self.max_body_size.as_u64() / 2);
ensure!(
self.shard_burst_limit >= estimated_persist_size,
"shard_burst_limit ({}) must be at least 1.5*content_length_limit ({})",
"shard_burst_limit ({}) must be at least 1.5*max_body_size ({})",
self.shard_burst_limit,
estimated_persist_size,
);
@@ -1150,6 +1167,54 @@ mod tests {
}
}
#[test]
fn test_ingest_api_max_body_size_default_and_alias() {
// Default.
let default_config = IngestApiConfig::default();
assert_eq!(default_config.max_body_size, ByteSize::mib(10));
// The former name `content_length_limit` is still accepted as an alias.
let ingest_api_config: IngestApiConfig = serde_yaml::from_str(
r#"
content_length_limit: 20M
"#,
)
.unwrap();
assert_eq!(ingest_api_config.max_body_size, ByteSize::mb(20));
assert!(ingest_api_config.validate().is_ok());
// The new name works too.
let ingest_api_config: IngestApiConfig = serde_yaml::from_str(
r#"
max_body_size: 20M
"#,
)
.unwrap();
assert_eq!(ingest_api_config.max_body_size, ByteSize::mb(20));
}
#[test]
fn test_ingest_api_max_decompressed_body_size() {
let default_config = IngestApiConfig::default();
assert_eq!(
default_config.max_decompressed_body_size,
ByteSize::mib(256)
);
// A decompressed limit below the wire body-size limit is rejected.
let ingest_api_config: IngestApiConfig = serde_yaml::from_str(
r#"
max_body_size: 10M
max_decompressed_body_size: 1M
"#,
)
.unwrap();
assert_eq!(
ingest_api_config.validate().unwrap_err().to_string(),
"max_decompressed_body_size (1.0 MB) must be at least max_body_size (10.0 MB)"
);
}
#[test]
fn test_ingest_api_config_decommission_timeout() {
let default_config = IngestApiConfig::default();
+241 -41
View File
@@ -16,21 +16,58 @@ use std::io::Read;
use std::sync::LazyLock;
use bytes::Bytes;
use bytesize::ByteSize;
use flate2::read::{MultiGzDecoder, ZlibDecoder};
use quickwit_common::metrics::IN_FLIGHT_REST_SERVER;
use quickwit_common::rate_limited_warn;
use quickwit_common::thread_pool::run_cpu_intensive;
use quickwit_metrics::GaugeGuard;
use quickwit_metrics::{GaugeGuard, counter};
use thiserror::Error;
use warp::Filter;
use warp::reject::Reject;
use crate::load_shield::{LoadShield, LoadShieldPermit};
use crate::metrics::DECOMPRESSION_TOO_LARGE_TOTAL;
fn get_ingest_load_shield() -> &'static LoadShield {
static LOAD_SHIELD: LazyLock<LoadShield> = LazyLock::new(|| LoadShield::new("ingest"));
&LOAD_SHIELD
}
/// Decodes `reader` into memory while enforcing `limit` on the decompressed size.
///
/// We read at most `limit + 1` bytes: that is enough to detect an overflow while guaranteeing we
/// never inflate (and therefore never allocate or spend CPU on) more than the limit, even when the
/// compressed input would expand to many gigabytes. This is what prevents decompression-bomb OOMs:
/// the on-the-wire content length limit only caps the *compressed* size, which says nothing about
/// how much a body expands.
fn decode_to_limit<R: Read>(
reader: R,
limit: ByteSize,
encoding: &str,
compressed_len: usize,
endpoint: &'static str,
) -> Result<Bytes, warp::Rejection> {
let mut decompressed = Vec::new();
reader
.take(limit.as_u64() + 1)
.read_to_end(&mut decompressed)
.map_err(|_| warp::reject::custom(CorruptedData))?;
if decompressed.len() as u64 > limit.as_u64() {
counter!(parent: DECOMPRESSION_TOO_LARGE_TOTAL, "endpoint" => endpoint).inc();
rate_limited_warn!(
limit_per_min = 6,
endpoint,
encoding,
compressed_len,
max_decompressed_body_size = limit.as_u64(),
"rejecting ingest request: decompressed body exceeds the maximum allowed size"
);
return Err(warp::reject::custom(DecompressedPayloadTooLarge(limit)));
}
Ok(Bytes::from(decompressed))
}
/// There are two ways to decompress the body:
/// - Stream the body through an async decompressor
/// - Fetch the body and then decompress the bytes
@@ -38,44 +75,54 @@ fn get_ingest_load_shield() -> &'static LoadShield {
/// The first approach lowers the latency, while the second approach is more CPU efficient.
/// Ingesting data is usually CPU bound and there is considerable latency until the data is
/// searchable, so the second approach is more suitable for this use case.
async fn decompress_body(encoding: Option<String>, body: Bytes) -> Result<Bytes, warp::Rejection> {
async fn decompress_body(
encoding: Option<String>,
body: Bytes,
max_decompressed_body_size: ByteSize,
endpoint: &'static str,
) -> Result<Bytes, warp::Rejection> {
match encoding.as_deref() {
// Uncompressed bodies cannot amplify, and are already bounded by the (compressed)
// content length limit filter applied upstream of this one, so no extra check is needed.
Some("identity") => Ok(body),
Some("gzip" | "x-gzip") => {
let decompressed = run_cpu_intensive(move || {
let mut decompressed = Vec::new();
let mut decoder = MultiGzDecoder::new(body.as_ref());
decoder
.read_to_end(&mut decompressed)
.map_err(|_| warp::reject::custom(CorruptedData))?;
Result::<_, warp::Rejection>::Ok(Bytes::from(decompressed))
})
.await
.map_err(|_| warp::reject::custom(CorruptedData))??;
Ok(decompressed)
}
Some("zstd") => {
let decompressed = run_cpu_intensive(move || {
zstd::decode_all(body.as_ref())
.map(Bytes::from)
.map_err(|_| warp::reject::custom(CorruptedData))
})
.await
.map_err(|_| warp::reject::custom(CorruptedData))??;
Ok(decompressed)
}
Some("deflate" | "x-deflate") => {
let decompressed = run_cpu_intensive(move || {
let mut decompressed = Vec::new();
ZlibDecoder::new(body.as_ref())
.read_to_end(&mut decompressed)
.map_err(|_| warp::reject::custom(CorruptedData))?;
Result::<_, warp::Rejection>::Ok(Bytes::from(decompressed))
})
.await
.map_err(|_| warp::reject::custom(CorruptedData))??;
Ok(decompressed)
}
Some("gzip" | "x-gzip") => run_cpu_intensive(move || {
let compressed_len = body.len();
decode_to_limit(
MultiGzDecoder::new(body.as_ref()),
max_decompressed_body_size,
"gzip",
compressed_len,
endpoint,
)
})
.await
.map_err(|_| warp::reject::custom(CorruptedData))?,
Some("zstd") => run_cpu_intensive(move || {
let compressed_len = body.len();
let decoder = zstd::Decoder::new(body.as_ref())
.map_err(|_| warp::reject::custom(CorruptedData))?;
decode_to_limit(
decoder,
max_decompressed_body_size,
"zstd",
compressed_len,
endpoint,
)
})
.await
.map_err(|_| warp::reject::custom(CorruptedData))?,
Some("deflate" | "x-deflate") => run_cpu_intensive(move || {
let compressed_len = body.len();
decode_to_limit(
ZlibDecoder::new(body.as_ref()),
max_decompressed_body_size,
"deflate",
compressed_len,
endpoint,
)
})
.await
.map_err(|_| warp::reject::custom(CorruptedData))?,
Some(encoding) => Err(warp::reject::custom(UnsupportedEncoding(
encoding.to_string(),
))),
@@ -95,13 +142,28 @@ pub(crate) struct UnsupportedEncoding(String);
impl Reject for UnsupportedEncoding {}
/// Custom filter for optional decompression
pub(crate) fn get_body_bytes() -> impl Filter<Extract = (Body,), Error = warp::Rejection> + Clone {
#[derive(Debug, Error)]
#[error(
"decompressed request body exceeds the maximum allowed size of {}",
self.0.display().si()
)]
pub(crate) struct DecompressedPayloadTooLarge(ByteSize);
impl Reject for DecompressedPayloadTooLarge {}
/// Custom filter for optional decompression.
///
/// `max_decompressed_body_size` bounds the size of the body *after* decompression, protecting
/// against decompression bombs (see [`decode_to_limit`]).
pub(crate) fn get_body_bytes(
max_decompressed_body_size: ByteSize,
endpoint: &'static str,
) -> impl Filter<Extract = (Body,), Error = warp::Rejection> + Clone {
warp::header::optional("content-encoding")
.and(warp::body::bytes())
.and_then(|encoding: Option<String>, body: Bytes| async move {
.and_then(move |encoding: Option<String>, body: Bytes| async move {
let permit = get_ingest_load_shield().acquire_permit().await?;
decompress_body(encoding, body)
decompress_body(encoding, body, max_decompressed_body_size, endpoint)
.await
.map(|content| Body::new(content, permit))
})
@@ -123,3 +185,141 @@ impl Body {
}
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use flate2::Compression;
use flate2::write::{GzEncoder, ZlibEncoder};
use super::*;
// A generous limit for tests that exercise the happy path.
const MAX_DECOMPRESSED: ByteSize = ByteSize::mib(256);
fn gzip(data: &[u8]) -> Vec<u8> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
fn deflate(data: &[u8]) -> Vec<u8> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
fn zstd_compress(data: &[u8]) -> Vec<u8> {
zstd::encode_all(data, 0).unwrap()
}
#[tokio::test]
async fn test_decompress_body_roundtrip_under_limit() {
let payload = b"hello world, this is a small ingest payload".repeat(100);
for (encoding, compressed) in [
("gzip", gzip(&payload)),
("zstd", zstd_compress(&payload)),
("deflate", deflate(&payload)),
] {
let decompressed = decompress_body(
Some(encoding.to_string()),
Bytes::from(compressed),
MAX_DECOMPRESSED,
"test",
)
.await
.unwrap_or_else(|_| panic!("`{encoding}` body should decompress"));
assert_eq!(
decompressed.as_ref(),
payload.as_slice(),
"encoding={encoding}"
);
}
}
#[tokio::test]
async fn test_decompress_body_identity_and_none_passthrough() {
let body = Bytes::from_static(b"raw uncompressed body");
let out = decompress_body(
Some("identity".to_string()),
body.clone(),
MAX_DECOMPRESSED,
"test",
)
.await
.unwrap();
assert_eq!(out, body);
let out = decompress_body(None, body.clone(), MAX_DECOMPRESSED, "test")
.await
.unwrap();
assert_eq!(out, body);
}
#[tokio::test]
async fn test_decompress_body_unsupported_encoding() {
let error = decompress_body(
Some("br".to_string()),
Bytes::from_static(b"x"),
MAX_DECOMPRESSED,
"test",
)
.await
.unwrap_err();
assert!(error.find::<UnsupportedEncoding>().is_some());
}
#[tokio::test]
async fn test_decompress_body_rejects_over_limit() {
// ~16 KiB decompressed against a 1 KiB limit: every encoding must be rejected, exercising
// the full `decompress_body` path (and the `take()` bound) without inflating anything
// large.
let payload = vec![0u8; 16 * 1024];
for (encoding, compressed) in [
("gzip", gzip(&payload)),
("zstd", zstd_compress(&payload)),
("deflate", deflate(&payload)),
] {
let error = decompress_body(
Some(encoding.to_string()),
Bytes::from(compressed),
ByteSize::kib(1),
"test",
)
.await
.unwrap_err();
assert!(
error.find::<DecompressedPayloadTooLarge>().is_some(),
"encoding={encoding}"
);
}
}
#[test]
fn test_decode_to_limit_accepts_up_to_limit() {
let data = vec![0u8; 100];
let out = decode_to_limit(
data.as_slice(),
ByteSize::b(100),
"identity",
data.len(),
"test",
)
.unwrap();
assert_eq!(out.len(), 100);
}
#[test]
fn test_decode_to_limit_rejects_overflow() {
let data = vec![0u8; 1024];
let error = decode_to_limit(
data.as_slice(),
ByteSize::b(100),
"identity",
data.len(),
"test",
)
.unwrap_err();
assert!(error.find::<DecompressedPayloadTooLarge>().is_some());
}
}
@@ -37,11 +37,12 @@ use crate::{Body, with_arg};
pub fn es_compat_bulk_handler(
ingest_service: IngestServiceClient,
ingest_router: IngestRouterServiceClient,
content_length_limit: ByteSize,
max_body_size: ByteSize,
max_decompressed_body_size: ByteSize,
enable_ingest_v1: bool,
enable_ingest_v2: bool,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
elastic_bulk_filter(content_length_limit)
elastic_bulk_filter(max_body_size, max_decompressed_body_size)
.and(with_arg(ingest_service))
.and(with_arg(ingest_router))
.then(move |body, bulk_options, ingest_service, ingest_router| {
@@ -64,11 +65,12 @@ pub fn es_compat_bulk_handler(
pub fn es_compat_index_bulk_handler(
ingest_service: IngestServiceClient,
ingest_router: IngestRouterServiceClient,
content_length_limit: ByteSize,
max_body_size: ByteSize,
max_decompressed_body_size: ByteSize,
enable_ingest_v1: bool,
enable_ingest_v2: bool,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
elastic_index_bulk_filter(content_length_limit)
elastic_index_bulk_filter(max_body_size, max_decompressed_body_size)
.and(with_arg(ingest_service))
.and(with_arg(ingest_router))
.then(
@@ -429,9 +429,9 @@ mod tests {
fn es_compat_bulk_handler_v2(
ingest_router: IngestRouterServiceClient,
content_length_limit: ByteSize,
max_body_size: ByteSize,
) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone {
elastic_bulk_filter(content_length_limit)
elastic_bulk_filter(max_body_size, ByteSize::mib(256))
.and(with_arg(ingest_router))
.then(|body, bulk_options, ingest_router| {
elastic_bulk_ingest_v2(None, body, bulk_options, ingest_router)
@@ -66,14 +66,16 @@ pub(crate) fn elasticsearch_filter()
)
)]
pub(crate) fn elastic_bulk_filter(
content_length_limit: ByteSize,
max_body_size: ByteSize,
max_decompressed_body_size: ByteSize,
) -> impl Filter<Extract = (Body, ElasticBulkOptions), Error = Rejection> + Clone {
warp::path!("_elastic" / "_bulk")
.and(warp::post().or(warp::put()).unify())
.and(warp::body::content_length_limit(
content_length_limit.as_u64(),
.and(warp::body::content_length_limit(max_body_size.as_u64()))
.and(get_body_bytes(
max_decompressed_body_size,
"elasticsearch_bulk",
))
.and(get_body_bytes())
.and(warp::query())
}
@@ -90,14 +92,16 @@ pub(crate) fn elastic_bulk_filter(
)
)]
pub(crate) fn elastic_index_bulk_filter(
content_length_limit: ByteSize,
max_body_size: ByteSize,
max_decompressed_body_size: ByteSize,
) -> impl Filter<Extract = (String, Body, ElasticBulkOptions), Error = Rejection> + Clone {
warp::path!("_elastic" / String / "_bulk")
.and(warp::post().or(warp::put()).unify())
.and(warp::body::content_length_limit(
content_length_limit.as_u64(),
.and(warp::body::content_length_limit(max_body_size.as_u64()))
.and(get_body_bytes(
max_decompressed_body_size,
"elasticsearch_bulk",
))
.and(get_body_bytes())
.and(warp::query::<ElasticBulkOptions>())
}
@@ -68,14 +68,16 @@ pub fn elastic_api_handlers(
enable_ingest_v1: bool,
enable_ingest_v2: bool,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
let ingest_content_length_limit = node_config.ingest_api_config.content_length_limit;
let max_body_size = node_config.ingest_api_config.max_body_size;
let max_decompressed_body_size = node_config.ingest_api_config.max_decompressed_body_size;
es_compat_cluster_info_handler(node_config.clone(), BuildInfo::get())
.or(es_compat_nodes_handler(node_config.clone()))
.or(es_compat_search_handler(search_service.clone()))
.or(es_compat_bulk_handler(
ingest_service.clone(),
ingest_router.clone(),
ingest_content_length_limit,
max_body_size,
max_decompressed_body_size,
enable_ingest_v1,
enable_ingest_v2,
))
@@ -83,7 +85,8 @@ pub fn elastic_api_handlers(
.or(es_compat_index_bulk_handler(
ingest_service,
ingest_router,
ingest_content_length_limit,
max_body_size,
max_decompressed_body_size,
enable_ingest_v1,
enable_ingest_v2,
))
@@ -95,9 +95,9 @@ fn ingest_filter(
warp::path!(String / "ingest")
.and(warp::post())
.and(warp::body::content_length_limit(
config.content_length_limit.as_u64(),
config.max_body_size.as_u64(),
))
.and(get_body_bytes())
.and(get_body_bytes(config.max_decompressed_body_size, "ingest"))
.and(warp::query::<IngestOptions>())
}
@@ -406,8 +406,7 @@ pub(crate) mod tests {
#[tokio::test]
async fn test_ingest_api_return_413_if_above_content_limit() {
let config: IngestApiConfig =
serde_json::from_str(r#"{ "content_length_limit": "1" }"#).unwrap();
let config: IngestApiConfig = serde_json::from_str(r#"{ "max_body_size": "1" }"#).unwrap();
let (universe, _temp_dir, ingest_service, _) =
setup_ingest_v1_service(&["my-index"], &IngestApiConfig::default()).await;
let ingest_router = IngestRouterServiceClient::mocked();
+7
View File
@@ -48,3 +48,10 @@ pub(crate) static CIRCUIT_BREAK_TOTAL: LazyCounter = lazy_counter!(
description: "Circuit breaker counter",
subsystem: "grpc",
);
pub(crate) static DECOMPRESSION_TOO_LARGE_TOTAL: LazyCounter = lazy_counter!(
name: "decompression_too_large_total",
description: "Number of ingest requests rejected because their decompressed body exceeded \
the maximum allowed size, labeled by `endpoint`.",
subsystem: "rest",
);
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use bytesize::ByteSize;
use quickwit_common::rate_limited_error;
use quickwit_opentelemetry::otlp::{OtelSignal, OtlpGrpcLogsService, OtlpGrpcTracesService};
use quickwit_proto::opentelemetry::proto::collector::logs::v1::logs_service_server::LogsService;
@@ -45,11 +46,18 @@ pub struct OtlpApi;
pub(crate) fn otlp_ingest_api_handlers(
otlp_logs_service: Option<OtlpGrpcLogsService>,
otlp_traces_service: Option<OtlpGrpcTracesService>,
max_decompressed_body_size: ByteSize,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
otlp_default_logs_handler(otlp_logs_service.clone())
.or(otlp_default_traces_handler(otlp_traces_service.clone()).recover(recover_fn))
.or(otlp_logs_handler(otlp_logs_service).recover(recover_fn))
.or(otlp_ingest_traces_handler(otlp_traces_service).recover(recover_fn))
otlp_default_logs_handler(otlp_logs_service.clone(), max_decompressed_body_size)
.or(
otlp_default_traces_handler(otlp_traces_service.clone(), max_decompressed_body_size)
.recover(recover_fn),
)
.or(otlp_logs_handler(otlp_logs_service, max_decompressed_body_size).recover(recover_fn))
.or(
otlp_ingest_traces_handler(otlp_traces_service, max_decompressed_body_size)
.recover(recover_fn),
)
.boxed()
}
@@ -65,6 +73,7 @@ pub(crate) fn otlp_ingest_api_handlers(
)]
pub(crate) fn otlp_default_logs_handler(
otlp_logs_service: Option<OtlpGrpcLogsService>,
max_decompressed_body_size: ByteSize,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
require(otlp_logs_service)
.and(warp::path!("otlp" / "v1" / "logs"))
@@ -76,7 +85,10 @@ pub(crate) fn otlp_default_logs_handler(
OtelSignal::Logs.header_name(),
))
.and(warp::post())
.and(get_body_bytes())
.and(warp::body::content_length_limit(
max_decompressed_body_size.as_u64(),
))
.and(get_body_bytes(max_decompressed_body_size, "otlp_logs"))
.then(
|otlp_logs_service, index_id: Option<String>, body| async move {
let index_id =
@@ -100,6 +112,7 @@ pub(crate) fn otlp_default_logs_handler(
)]
pub(crate) fn otlp_logs_handler(
otlp_log_service: Option<OtlpGrpcLogsService>,
max_decompressed_body_size: ByteSize,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
require(otlp_log_service)
.and(warp::path!(String / "otlp" / "v1" / "logs"))
@@ -108,7 +121,10 @@ pub(crate) fn otlp_logs_handler(
"application/x-protobuf",
))
.and(warp::post())
.and(get_body_bytes())
.and(warp::body::content_length_limit(
max_decompressed_body_size.as_u64(),
))
.and(get_body_bytes(max_decompressed_body_size, "otlp_logs"))
.then(otlp_ingest_logs)
.and(with_arg(BodyFormat::default()))
.map(into_rest_api_response)
@@ -127,6 +143,7 @@ pub(crate) fn otlp_logs_handler(
)]
pub(crate) fn otlp_default_traces_handler(
otlp_traces_service: Option<OtlpGrpcTracesService>,
max_decompressed_body_size: ByteSize,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
require(otlp_traces_service)
.and(warp::path!("otlp" / "v1" / "traces"))
@@ -138,7 +155,10 @@ pub(crate) fn otlp_default_traces_handler(
OtelSignal::Traces.header_name(),
))
.and(warp::post())
.and(get_body_bytes())
.and(warp::body::content_length_limit(
max_decompressed_body_size.as_u64(),
))
.and(get_body_bytes(max_decompressed_body_size, "otlp_traces"))
.then(
|otlp_traces_service, index_id: Option<String>, body| async move {
let index_id =
@@ -162,6 +182,7 @@ pub(crate) fn otlp_default_traces_handler(
)]
pub(crate) fn otlp_ingest_traces_handler(
otlp_traces_service: Option<OtlpGrpcTracesService>,
max_decompressed_body_size: ByteSize,
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
require(otlp_traces_service)
.and(warp::path!(String / "otlp" / "v1" / "traces"))
@@ -170,7 +191,10 @@ pub(crate) fn otlp_ingest_traces_handler(
"application/x-protobuf",
))
.and(warp::post())
.and(get_body_bytes())
.and(warp::body::content_length_limit(
max_decompressed_body_size.as_u64(),
))
.and(get_body_bytes(max_decompressed_body_size, "otlp_traces"))
.then(otlp_ingest_traces)
.and(with_arg(BodyFormat::default()))
.map(into_rest_api_response)
@@ -245,6 +269,7 @@ async fn otlp_ingest_traces(
mod tests {
use std::io::Write;
use bytesize::ByteSize;
use flate2::Compression;
use flate2::write::GzEncoder;
use prost::Message;
@@ -353,7 +378,8 @@ mod tests {
};
let body = export_logs_request.encode_to_vec();
let otlp_traces_api_handler =
otlp_ingest_api_handlers(Some(logs_service), Some(traces_service)).recover(recover_fn);
otlp_ingest_api_handlers(Some(logs_service), Some(traces_service), ByteSize::mib(256))
.recover(recover_fn);
{
// Test default otlp endpoint
let resp = warp::test::request()
@@ -499,7 +525,8 @@ mod tests {
};
let body = export_trace_request.encode_to_vec();
let otlp_traces_api_handler =
otlp_ingest_api_handlers(Some(logs_service), Some(traces_service)).recover(recover_fn);
otlp_ingest_api_handlers(Some(logs_service), Some(traces_service), ByteSize::mib(256))
.recover(recover_fn);
{
// Test default otlp endpoint
let resp = warp::test::request()
+10 -1
View File
@@ -45,7 +45,7 @@ use warp::hyper::{Method, StatusCode, http};
use warp::{Filter, Rejection, Reply, redirect};
use crate::cluster_api::cluster_handler;
use crate::decompression::{CorruptedData, UnsupportedEncoding};
use crate::decompression::{CorruptedData, DecompressedPayloadTooLarge, UnsupportedEncoding};
use crate::delete_task_api::delete_task_api_handlers;
use crate::developer_api::developer_api_routes;
use crate::elasticsearch_api::elastic_api_handlers;
@@ -554,6 +554,10 @@ fn api_v1_routes(
.or(otlp_ingest_api_handlers(
quickwit_services.otlp_logs_service_opt.clone(),
quickwit_services.otlp_traces_service_opt.clone(),
quickwit_services
.node_config
.ingest_api_config
.max_decompressed_body_size,
))
.boxed()
.or(index_management_handlers(
@@ -659,6 +663,11 @@ fn get_status_with_error(rejection: Rejection) -> Result<RestApiError, Rejection
status_code: StatusCode::BAD_REQUEST,
message: error.to_string(),
})
} else if let Some(error) = rejection.find::<DecompressedPayloadTooLarge>() {
Ok(RestApiError {
status_code: StatusCode::PAYLOAD_TOO_LARGE,
message: error.to_string(),
})
} else if let Some(error) = rejection.find::<warp::reject::InvalidQuery>() {
Ok(RestApiError {
status_code: StatusCode::BAD_REQUEST,