Skip to content

Commit 9bbc119

Browse files
committed
test: assert the invocation ID reaches the wire
The existing tests all patch runtime_client, so they verify Python-level argument threading but stop one layer above the behaviour. The header is emitted by libcurl inside the compiled extension. If aws-lambda-cpp-add-invocation-id.patch is dropped when the vendored tarball is re-rolled, every test still passes and the feature silently disappears. Add tests that drive the built extension against a stub RAPID on a real socket, asserting on raw received headers: round trip via /response, the /error path, and omission (no header at all, rather than an empty one). Verified by removing the curl_slist_append call and rebuilding — the new tests fail while the existing suite stays green. The client half runs as a subprocess because post_invocation_result and post_error hold the GIL across the blocking curl call, so an in-process stub server thread would never be scheduled to answer the POST.
1 parent a5775f7 commit 9bbc119

4 files changed

Lines changed: 221 additions & 1 deletion

File tree

.github/workflows/test-on-push-and-pr.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ jobs:
1818
- name: Run 'pr' target
1919
run: make pr
2020

21+
test-runtime-client:
22+
name: test runtime client
23+
runs-on: ubuntu-latest
24+
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
# The tests in this job drive the compiled extension against a stub RAPID,
29+
# so unlike the unit tests they need the native code built first.
30+
- name: Install aws-lambda-cpp build dependencies
31+
run: |
32+
sudo apt-get update
33+
sudo apt-get install -y g++ make cmake libcurl4-openssl-dev
34+
35+
- name: Install requirements
36+
run: make init
37+
38+
- name: Compile the runtime client extension and run its tests
39+
run: make test-runtime-client
40+
2141
integration-test:
2242
runs-on: ubuntu-latest
2343
strategy:

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@ init:
88
pip3 install -r requirements/base.txt -r requirements/dev.txt
99

1010
.PHONY: test
11+
# test_runtime_client_headers.py needs the compiled extension, so it runs in its
12+
# own target (test-runtime-client) rather than here.
1113
test: check-format
12-
pytest --cov awslambdaric --cov-report term-missing --cov-fail-under 90 tests
14+
pytest --cov awslambdaric --cov-report term-missing --cov-fail-under 90 \
15+
--ignore tests/test_runtime_client_headers.py tests
16+
17+
.PHONY: test-runtime-client
18+
test-runtime-client:
19+
python3 setup.py build_ext --inplace
20+
pytest -v tests/test_runtime_client_headers.py
1321

1422
.PHONY: test-integ
1523
test-integ:

tests/invocation_id_probe.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
4+
Client half of test_runtime_client_headers.py, run as a subprocess.
5+
6+
It is a separate process rather than a thread because the native
7+
post_invocation_result/post_error entry points hold the GIL for the duration of
8+
the blocking curl call so an in-process stub server thread could never be scheduled to answer the POST.
9+
"""
10+
11+
import json
12+
import sys
13+
14+
import runtime_client
15+
16+
INVOCATION_ID_HEADER = "Lambda-Runtime-Invocation-Id"
17+
REQUEST_ID_HEADER = "Lambda-Runtime-Aws-Request-Id"
18+
19+
20+
def main(mode):
21+
runtime_client.initialize_client("test-agent")
22+
23+
_, headers = runtime_client.next()
24+
invocation_id = headers[INVOCATION_ID_HEADER]
25+
26+
# Thread the invocation id straight back, exactly as bootstrap does. Passing
27+
# it through untouched is what makes the POST assertions meaningful.
28+
if mode == "response":
29+
runtime_client.post_invocation_result(
30+
headers[REQUEST_ID_HEADER],
31+
b'{"ok": true}',
32+
"application/json",
33+
invocation_id,
34+
)
35+
elif mode == "error":
36+
runtime_client.post_error(
37+
headers[REQUEST_ID_HEADER],
38+
'{"errorMessage": "boom"}',
39+
"{}",
40+
invocation_id,
41+
)
42+
else:
43+
raise SystemExit(f"unknown mode: {mode!r}")
44+
45+
print(json.dumps({"next_invocation_id": invocation_id}))
46+
47+
48+
if __name__ == "__main__":
49+
if len(sys.argv) != 2:
50+
raise SystemExit(__doc__)
51+
main(sys.argv[1])
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""
2+
Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
"""
4+
5+
import http.server
6+
import json
7+
import os
8+
import subprocess
9+
import sys
10+
import threading
11+
import unittest
12+
13+
INVOCATION_ID_HEADER = "Lambda-Runtime-Invocation-Id"
14+
REQUEST_ID = "request-id-1"
15+
16+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17+
CLIENT_PROBE = os.path.join(
18+
os.path.dirname(os.path.abspath(__file__)), "invocation_id_probe.py"
19+
)
20+
21+
22+
class StubRapidHandler(http.server.BaseHTTPRequestHandler):
23+
"""Minimal stand-in for the RAPID /next, /response and /error endpoints.
24+
25+
Each request's raw header block is appended to the server's `received` list
26+
so that tests can assert on what actually went over the wire rather than on
27+
what the Python layer believed it was sending.
28+
"""
29+
30+
protocol_version = "HTTP/1.1"
31+
32+
def log_message(self, format, *args):
33+
pass # keep the test output clean
34+
35+
def _record(self):
36+
self.server.received.append((self.path, self.headers))
37+
38+
def do_GET(self):
39+
self._record()
40+
body = b"{}"
41+
self.send_response(200)
42+
self.send_header("Lambda-Runtime-Aws-Request-Id", REQUEST_ID)
43+
self.send_header("Lambda-Runtime-Invoked-Function-Arn", "arn:aws:lambda:::f")
44+
self.send_header("Lambda-Runtime-Deadline-Ms", "1735689600000")
45+
self.send_header("Content-Type", "application/json")
46+
if self.server.invocation_id is not None:
47+
self.send_header(INVOCATION_ID_HEADER, self.server.invocation_id)
48+
self.send_header("Content-Length", str(len(body)))
49+
self.end_headers()
50+
self.wfile.write(body)
51+
52+
def do_POST(self):
53+
self._record()
54+
length = int(self.headers.get("Content-Length") or 0)
55+
if length:
56+
self.rfile.read(length)
57+
self.send_response(202)
58+
self.send_header("Content-Length", "0")
59+
self.end_headers()
60+
61+
62+
class StubRapid(http.server.ThreadingHTTPServer):
63+
64+
def __init__(self):
65+
super().__init__(("127.0.0.1", 0), StubRapidHandler)
66+
self.invocation_id = None
67+
self.received = []
68+
69+
@property
70+
def endpoint(self):
71+
return "{}:{}".format(*self.server_address)
72+
73+
74+
class TestInvocationIdOnTheWire(unittest.TestCase):
75+
"""End-to-end coverage of the Lambda-Runtime-Invocation-Id echo.
76+
77+
These tests drive the compiled extension against a real socket, so they
78+
cover the header-emitting C++ code in aws-lambda-cpp that the mocked unit
79+
tests in test_lambda_runtime_client.py cannot reach. If
80+
aws-lambda-cpp-add-invocation-id.patch is ever dropped from the vendored
81+
tarball, these are the tests that fail.
82+
"""
83+
84+
def _run_client(self, invocation_id, mode="response"):
85+
"""Serve one invocation to a real client and return its POST headers."""
86+
server = StubRapid()
87+
server.invocation_id = invocation_id
88+
thread = threading.Thread(target=server.serve_forever, daemon=True)
89+
thread.start()
90+
self.addCleanup(thread.join, 5)
91+
self.addCleanup(server.shutdown)
92+
self.addCleanup(server.server_close)
93+
94+
env = dict(
95+
os.environ,
96+
AWS_LAMBDA_RUNTIME_API=server.endpoint,
97+
PYTHONPATH=os.pathsep.join(
98+
[REPO_ROOT]
99+
+ ([os.environ["PYTHONPATH"]] if os.environ.get("PYTHONPATH") else [])
100+
),
101+
)
102+
completed = subprocess.run(
103+
[sys.executable, CLIENT_PROBE, mode],
104+
env=env,
105+
cwd=REPO_ROOT,
106+
capture_output=True,
107+
text=True,
108+
timeout=30,
109+
)
110+
self.assertEqual(
111+
0,
112+
completed.returncode,
113+
f"client failed:\nstdout={completed.stdout}\nstderr={completed.stderr}",
114+
)
115+
116+
posted = [h for path, h in server.received if path.endswith("/" + mode)]
117+
self.assertEqual(1, len(posted), f"expected exactly one POST to /{mode}")
118+
return json.loads(completed.stdout), posted[0]
119+
120+
def test_invocation_id_is_echoed_on_response(self):
121+
seen, posted = self._run_client("inv-uuid-round-trip")
122+
123+
self.assertEqual("inv-uuid-round-trip", seen["next_invocation_id"])
124+
# get_all, not get: a duplicated header must be visible, not normalized.
125+
self.assertEqual(["inv-uuid-round-trip"], posted.get_all(INVOCATION_ID_HEADER))
126+
127+
def test_invocation_id_is_echoed_on_error(self):
128+
seen, posted = self._run_client("inv-uuid-error-path", mode="error")
129+
130+
self.assertEqual("inv-uuid-error-path", seen["next_invocation_id"])
131+
self.assertEqual(["inv-uuid-error-path"], posted.get_all(INVOCATION_ID_HEADER))
132+
133+
def test_no_header_is_sent_when_rapid_did_not_send_one(self):
134+
seen, posted = self._run_client(None)
135+
136+
self.assertIsNone(seen["next_invocation_id"])
137+
self.assertIsNone(posted.get_all(INVOCATION_ID_HEADER))
138+
139+
140+
if __name__ == "__main__":
141+
unittest.main()

0 commit comments

Comments
 (0)