Skip to content

Commit 12703d9

Browse files
fix(lifecycle): raise axum body limit to 6 MB for large Lambda payloads (#1044)
https://datadoghq.atlassian.net/browse/SVLS-8609 Issue reported: #1041 ## Summary - axum 0.7+ applies a 2 MB default body limit globally; the `/lambda/start-invocation` endpoint was rejecting payloads larger than 2 MB with `length limit exceeded` - Raises the limit to 6 MB (`DefaultBodyLimit::max(6 * 1024 * 1024)`) to match [Lambda's maximum synchronous invocation payload size](https://docs.aws.amazon.com/lambda/latest/api/API_Invoke.html) - Adds three unit tests covering: accept above the old 2 MB default, accept at boundary (6 MB − 1 byte), reject above 6 MB (413) ## Test plan - Unit tests - Run `bash local_tests/repro-large-payload.sh` which sets up the local test env and sends a 3 MB payload to `/lambda/start-invocation` and verifies HTTP 200 with no `length limit exceeded` error | Scenario|Log| |--------|--------| | Without the fix|```{"level":"ERROR","message":"DD_EXTENSION \| ERROR \| Failed to extract request body: Failed to buffer the request body: length limit exceeded"}``` | | With the fix| ```{"level":"DEBUG","message":"DD_EXTENSION \| DEBUG \| Received start invocation request from headers:{\"datadog-meta-lang\": \"java\", \"user-agent\": \"curl/8.3.0\", \"host\": \"localhost:8124\", \"lambda-runtime-aws-request-id\": \"test-large-payload-request\", \"expect\": \"100-continue\", \"accept\": \"*/*\", \"content-length\": \"3200074\", \"content-type\": \"application/json\"}"}``` |
1 parent 6e75acf commit 12703d9

File tree

5 files changed

+235
-1
lines changed

5 files changed

+235
-1
lines changed

bottlecap/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bottlecap/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ libddwaf = { version = "1.28.1", git = "https://github.com/DataDog/libddwaf-rust
8484
figment = { version = "0.10", default-features = false, features = ["yaml", "env", "test"] }
8585
proptest = "1.4"
8686
httpmock = "0.7"
87+
tower = { version = "0.5", features = ["util"] }
8788
mock_instant = "0.6"
8889
serial_test = "3.1"
8990
tempfile = "3.20"

bottlecap/src/lifecycle/listener.rs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
use axum::{
55
Router,
6-
extract::{Request, State},
6+
extract::{DefaultBodyLimit, Request, State},
77
http::{HeaderMap, StatusCode},
88
response::{IntoResponse, Response},
99
routing::{get, post},
@@ -37,6 +37,9 @@ const HELLO_PATH: &str = "/lambda/hello";
3737
const START_INVOCATION_PATH: &str = "/lambda/start-invocation";
3838
const END_INVOCATION_PATH: &str = "/lambda/end-invocation";
3939
const AGENT_PORT: usize = 8124;
40+
// Lambda's maximum synchronous invocation payload size
41+
// reference: https://docs.aws.amazon.com/lambda/latest/api/API_Invoke.html
42+
const LAMBDA_INVOCATION_MAX_PAYLOAD: usize = 6 * 1024 * 1024;
4043

4144
/// Extracts the AWS Lambda request ID from the LWA proxy header.
4245
fn extract_request_id_from_headers(headers: &HashMap<String, String>) -> Option<String> {
@@ -102,6 +105,7 @@ impl Listener {
102105
.route(END_INVOCATION_PATH, post(Self::handle_end_invocation))
103106
.route(HELLO_PATH, get(Self::handle_hello))
104107
.with_state(state)
108+
.layer(DefaultBodyLimit::max(LAMBDA_INVOCATION_MAX_PAYLOAD))
105109
}
106110

107111
async fn graceful_shutdown(tasks: Arc<Mutex<JoinSet<()>>>, shutdown_token: CancellationToken) {
@@ -270,6 +274,83 @@ impl Listener {
270274
#[cfg(test)]
271275
mod tests {
272276
use super::*;
277+
use axum::{body::Body, http::Request, routing::post};
278+
use http_body_util::BodyExt;
279+
use tower::ServiceExt;
280+
281+
/// Builds a minimal router that applies only the body limit layer.
282+
/// The handler reads the full body (via the `Bytes` extractor), which
283+
/// is what triggers `DefaultBodyLimit` enforcement.
284+
fn body_limit_router() -> Router {
285+
async fn handler(body: Bytes) -> StatusCode {
286+
let _ = body;
287+
StatusCode::OK
288+
}
289+
Router::new()
290+
.route("/lambda/start-invocation", post(handler))
291+
.layer(DefaultBodyLimit::max(LAMBDA_INVOCATION_MAX_PAYLOAD))
292+
}
293+
294+
#[tokio::test]
295+
async fn test_body_limit_accepts_payload_just_below_6mb() {
296+
let router = body_limit_router();
297+
// 6 MB - 1 byte: should be accepted
298+
let payload = vec![b'x'; LAMBDA_INVOCATION_MAX_PAYLOAD - 1];
299+
let req = Request::builder()
300+
.method("POST")
301+
.uri("/lambda/start-invocation")
302+
.header("Content-Type", "application/json")
303+
.body(Body::from(payload))
304+
.expect("failed to build request");
305+
306+
let response = router.oneshot(req).await.expect("request failed");
307+
assert_eq!(response.status(), StatusCode::OK);
308+
}
309+
310+
#[tokio::test]
311+
async fn test_body_limit_accepts_payload_above_old_2mb_default() {
312+
let router = body_limit_router();
313+
// 3 MB: above the old axum 2 MB default, should now succeed
314+
let payload = vec![b'x'; 3 * 1024 * 1024];
315+
let req = Request::builder()
316+
.method("POST")
317+
.uri("/lambda/start-invocation")
318+
.header("Content-Type", "application/json")
319+
.body(Body::from(payload))
320+
.expect("failed to build request");
321+
322+
let response = router.oneshot(req).await.expect("request failed");
323+
assert_eq!(response.status(), StatusCode::OK);
324+
}
325+
326+
#[tokio::test]
327+
async fn test_body_limit_rejects_payload_above_6mb() {
328+
let router = body_limit_router();
329+
// 6 MB + 1 byte: should be rejected with 413
330+
let payload = vec![b'x'; LAMBDA_INVOCATION_MAX_PAYLOAD + 1];
331+
let req = Request::builder()
332+
.method("POST")
333+
.uri("/lambda/start-invocation")
334+
.header("Content-Type", "application/json")
335+
.body(Body::from(payload))
336+
.expect("failed to build request");
337+
338+
let response = router.oneshot(req).await.expect("request failed");
339+
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
340+
341+
let body = response
342+
.into_body()
343+
.collect()
344+
.await
345+
.expect("failed to read body")
346+
.to_bytes();
347+
assert!(
348+
body.windows(b"length limit exceeded".len())
349+
.any(|w| w == b"length limit exceeded"),
350+
"expected 'length limit exceeded' in response body, got: {}",
351+
String::from_utf8_lossy(&body)
352+
);
353+
}
273354

274355
#[test]
275356
fn test_extract_request_id_from_header() {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# No Lambda RIE — runs the extension directly against a minimal mock
2+
# Extensions API server. Uses amazonlinux:2 to match the build environment
3+
# (same glibc / library versions as Dockerfile.build-bottlecap).
4+
FROM --platform=linux/amd64 amazonlinux:2
5+
6+
RUN yum install -y curl python3
7+
8+
RUN mkdir -p /opt/extensions
9+
COPY datadog-agent /opt/extensions/datadog-agent
10+
RUN chmod +x /opt/extensions/datadog-agent
11+
12+
COPY mock-extensions-api.py /mock-extensions-api.py
13+
COPY entrypoint.sh /entrypoint.sh
14+
RUN chmod +x /entrypoint.sh
15+
16+
# Extension configuration
17+
ENV DD_API_KEY=fake-key-for-local-test
18+
ENV DD_APM_DD_URL=http://127.0.0.1:3333
19+
ENV DD_DD_URL=http://127.0.0.1:3333
20+
ENV DD_TRACE_ENABLED=false
21+
ENV DD_LOG_LEVEL=DEBUG
22+
23+
# Point the extension at our mock Lambda Extensions API
24+
ENV AWS_LAMBDA_RUNTIME_API=127.0.0.1:9001
25+
ENV AWS_LAMBDA_FUNCTION_NAME=large-payload-test
26+
ENV AWS_LAMBDA_FUNCTION_MEMORY_SIZE=512
27+
ENV AWS_REGION=us-east-1
28+
29+
ENTRYPOINT ["/entrypoint.sh"]

local_tests/repro-large-payload.sh

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/bin/bash
2+
# repro-large-payload.sh
3+
# Reproduces GitHub issue #1041: extension errors on Lambda payloads > 2 MB.
4+
#
5+
# Strategy: POST the large payload directly to the extension's
6+
# /lambda/start-invocation endpoint (port 8124), exactly as the DD Java agent
7+
# does in production. The extension binds to 127.0.0.1:8124 (loopback only),
8+
# so we write the payload to a file, docker-cp it into the container, and
9+
# send the request from inside the container via docker exec.
10+
#
11+
# Run from the repo root:
12+
# bash local_tests/repro-large-payload.sh
13+
14+
set -euo pipefail
15+
16+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
18+
IMAGE_NAME="dd-extension-large-payload-repro"
19+
CONTAINER_ID=""
20+
LOG_FILE="$SCRIPT_DIR/large-payload-repro.log"
21+
PAYLOAD_FILE=$(mktemp /tmp/large-payload-XXXXXX.json)
22+
23+
# 3 MB — above the old 2 MB axum default, below the new 6 MB limit.
24+
PAYLOAD_CHARS=3200000
25+
26+
cleanup() {
27+
rm -f "$PAYLOAD_FILE"
28+
if [[ -n "$CONTAINER_ID" ]]; then
29+
docker logs "$CONTAINER_ID" > "$LOG_FILE" 2>&1 || true
30+
docker stop "$CONTAINER_ID" > /dev/null 2>&1 || true
31+
docker rm "$CONTAINER_ID" > /dev/null 2>&1 || true
32+
fi
33+
docker rmi "$IMAGE_NAME" > /dev/null 2>&1 || true
34+
}
35+
trap cleanup EXIT INT TERM
36+
37+
# Always rebuild the Linux x86_64 binary from the current source.
38+
# Mirrors the official AL2 build environment (images/Dockerfile.bottlecap.compile).
39+
echo "==> Building Linux extension binary (~10-20 min first run, cached after)..."
40+
rm -f "$SCRIPT_DIR/datadog-agent"
41+
docker build \
42+
--platform linux/amd64 \
43+
-f "$SCRIPT_DIR/Dockerfile.build-bottlecap" \
44+
-t dd-bottlecap-builder \
45+
"$REPO_ROOT"
46+
cid=$(docker create dd-bottlecap-builder)
47+
docker cp "$cid:/bottlecap" "$SCRIPT_DIR/datadog-agent"
48+
docker rm "$cid" > /dev/null
49+
docker rmi dd-bottlecap-builder > /dev/null 2>&1 || true
50+
chmod +x "$SCRIPT_DIR/datadog-agent"
51+
52+
echo "==> Building test image..."
53+
docker build \
54+
--no-cache \
55+
--platform linux/amd64 \
56+
-f "$SCRIPT_DIR/Dockerfile.LargePayload" \
57+
-t "$IMAGE_NAME" \
58+
"$SCRIPT_DIR"
59+
60+
echo "==> Starting container..."
61+
CONTAINER_ID=$(docker run -d --platform linux/amd64 "$IMAGE_NAME")
62+
63+
echo "==> Waiting for extension to bind port 8124..."
64+
READY=false
65+
for _ in $(seq 1 30); do
66+
if ! docker inspect "$CONTAINER_ID" --format='{{.State.Running}}' 2>/dev/null | grep -q "true"; then
67+
echo "ERROR: Container exited during init. Logs:"
68+
docker logs "$CONTAINER_ID" 2>&1 | tail -30
69+
exit 1
70+
fi
71+
if docker exec "$CONTAINER_ID" \
72+
curl -sf -o /dev/null \
73+
-X POST "http://localhost:8124/lambda/start-invocation" \
74+
-H "Content-Type: application/json" \
75+
-d '{}' --max-time 2 2>/dev/null; then
76+
READY=true
77+
break
78+
fi
79+
sleep 1
80+
done
81+
82+
if [[ "$READY" != "true" ]]; then
83+
echo "ERROR: Extension did not become ready after 30s. Logs:"
84+
docker logs "$CONTAINER_ID" 2>&1
85+
exit 1
86+
fi
87+
88+
echo "==> Sending ~3 MB payload to /lambda/start-invocation..."
89+
python3 -c "
90+
import json
91+
payload = {'description': 'Large payload repro for GitHub issue #1041', 'data': 'x' * $PAYLOAD_CHARS}
92+
print(json.dumps(payload))
93+
" > "$PAYLOAD_FILE"
94+
95+
PAYLOAD_SIZE=$(wc -c < "$PAYLOAD_FILE")
96+
docker cp "$PAYLOAD_FILE" "$CONTAINER_ID:/tmp/large-payload.json"
97+
98+
HTTP_CODE=$(docker exec "$CONTAINER_ID" \
99+
curl -s -o /dev/null -w "%{http_code}" \
100+
-X POST "http://localhost:8124/lambda/start-invocation" \
101+
-H "Content-Type: application/json" \
102+
-H "lambda-runtime-aws-request-id: test-large-payload-request" \
103+
-H "datadog-meta-lang: java" \
104+
--data-binary "@/tmp/large-payload.json" \
105+
--max-time 15) || HTTP_CODE="error"
106+
sleep 1
107+
108+
ERRORS=$(docker logs "$CONTAINER_ID" 2>&1 | grep -E "length limit|extract request body" || true)
109+
110+
echo ""
111+
echo "────────────────────────────────────────────────────────────"
112+
if [[ -n "$ERRORS" ]]; then
113+
echo "RESULT: BUG REPRODUCED (fix not applied or not working)"
114+
echo ""
115+
echo "$ERRORS"
116+
else
117+
echo "RESULT: OK — no 'length limit exceeded' error (fix is working)"
118+
echo " HTTP $HTTP_CODE returned for a ${PAYLOAD_SIZE}-byte payload"
119+
fi
120+
echo "────────────────────────────────────────────────────────────"
121+
echo ""
122+
echo "Full logs saved to: $LOG_FILE"

0 commit comments

Comments
 (0)