Skip to content

Commit c5f10f2

Browse files
author
Alex Wang
committed
fix(insight): make HttpExporter timeout_ms a request deadline
- http_send: use http.client with a timer that shuts the socket down when the deadline expires, then raises TimeoutError; a peer trickling bytes can no longer keep a request alive past timeout_ms - redirects stay unfollowed (http.client never follows them); the urllib opener is removed - HttpExporter docstring states the deadline semantics - tests: trickling status line ends in ~0.5 s with timeout_ms=500; unsupported URL scheme is rejected
1 parent 5a09149 commit c5f10f2

3 files changed

Lines changed: 135 additions & 35 deletions

File tree

packages/aws-durable-execution-sdk-python-insight/src/aws_durable_execution_sdk_python_insight/exporters/_common.py

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,21 @@
1111
from __future__ import annotations
1212

1313
import datetime
14+
import http.client
1415
import json
1516
import re
16-
import urllib.error
17-
import urllib.request
17+
import socket
18+
import ssl
19+
import threading
1820
from typing import Any
21+
from urllib.parse import urlsplit
1922

2023

2124
_SQL_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
2225
# Upper bound on how much of a non-2xx response body is read for diagnostics.
2326
_MAX_ERROR_BODY_BYTES = 64 * 1024
2427

2528

26-
class _NoRedirect(urllib.request.HTTPRedirectHandler):
27-
"""Refuse every redirect so a 3xx surfaces as a failed status.
28-
29-
Following a redirect would re-send a POST as a body-less GET and forward
30-
configured credential headers to the new location.
31-
"""
32-
33-
def redirect_request( # type: ignore[override] # stdlib signature has no hints
34-
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
35-
) -> None:
36-
return None
37-
38-
39-
_OPENER = urllib.request.build_opener(_NoRedirect())
40-
41-
4229
def compact_dumps(value: Any) -> str:
4330
"""Serialize ``value`` as compact JSON (no whitespace, non-ASCII preserved).
4431
@@ -86,20 +73,74 @@ def http_send(
8673
"""Send one HTTP request and return ``(status, reason, error_text)``.
8774
8875
A non-2xx status is returned, not raised, so callers build their own error
89-
message. Redirects are not followed: a 3xx is returned like any other
90-
failure. ``error_text`` is the first ``_MAX_ERROR_BODY_BYTES`` of a non-2xx
91-
response body and empty on success; a success body is never read. Network
92-
errors and timeouts propagate.
76+
message. Redirects are never followed: a 3xx is returned like any other
77+
failure, so a POST is not re-sent as a body-less GET and configured
78+
credential headers never reach another origin. ``error_text`` is the first
79+
``_MAX_ERROR_BODY_BYTES`` of a non-2xx response body and empty on success;
80+
a success body is never read.
81+
82+
``timeout`` (seconds) is a deadline for the whole request: connect, send,
83+
and receiving the status and headers. When it expires the connection is
84+
shut down and ``TimeoutError`` is raised, even against a peer that keeps
85+
the socket alive by trickling bytes. ``None`` means no limit. Network
86+
errors propagate.
9387
"""
94-
request = urllib.request.Request(url, data=body, method=method)
95-
for key, value in headers.items():
96-
request.add_header(key, value)
88+
parts = urlsplit(url)
89+
if parts.scheme not in ("http", "https") or not parts.hostname:
90+
msg = f"Unsupported URL: {url!r} (need http:// or https:// with a host)"
91+
raise ValueError(msg)
92+
path = parts.path or "/"
93+
if parts.query:
94+
path = f"{path}?{parts.query}"
95+
conn: http.client.HTTPConnection
96+
if parts.scheme == "https":
97+
conn = http.client.HTTPSConnection(
98+
parts.hostname,
99+
parts.port,
100+
timeout=timeout,
101+
context=ssl.create_default_context(),
102+
)
103+
else:
104+
conn = http.client.HTTPConnection(parts.hostname, parts.port, timeout=timeout)
105+
106+
expired = threading.Event()
107+
108+
def _expire() -> None:
109+
# Wake any blocked read; the request then fails and is reported as a
110+
# timeout below.
111+
expired.set()
112+
sock = conn.sock
113+
if sock is not None:
114+
try:
115+
sock.shutdown(socket.SHUT_RDWR)
116+
except OSError:
117+
pass
118+
119+
timer: threading.Timer | None = None
120+
if timeout is not None:
121+
timer = threading.Timer(timeout, _expire)
122+
timer.daemon = True
123+
timer.start()
97124
try:
98-
with _OPENER.open(request, timeout=timeout) as response: # noqa: S310
99-
return int(response.status), str(response.reason or ""), ""
100-
except urllib.error.HTTPError as exc:
101-
try:
102-
detail = exc.read(_MAX_ERROR_BODY_BYTES).decode("utf-8", errors="replace")
103-
except Exception: # noqa: BLE001 - the body is best-effort detail only
104-
detail = ""
105-
return int(exc.code), str(exc.reason or ""), detail
125+
conn.request(method, path, body=body, headers=headers)
126+
response = conn.getresponse()
127+
status = int(response.status)
128+
reason = str(response.reason or "")
129+
detail = ""
130+
if not 200 <= status < 300:
131+
try:
132+
detail = response.read(_MAX_ERROR_BODY_BYTES).decode(
133+
"utf-8", errors="replace"
134+
)
135+
except Exception: # noqa: BLE001 - the body is best-effort detail only
136+
detail = ""
137+
return status, reason, detail
138+
except Exception as exc:
139+
if expired.is_set():
140+
msg = f"request to {url} exceeded {timeout}s"
141+
raise TimeoutError(msg) from exc
142+
raise
143+
finally:
144+
if timer is not None:
145+
timer.cancel()
146+
conn.close()

packages/aws-durable-execution-sdk-python-insight/src/aws_durable_execution_sdk_python_insight/exporters/http_exporter.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ class HttpMethod(StrEnum):
3333
class HttpExporter:
3434
"""Sends each record as a JSON body to any HTTP endpoint.
3535
36-
The endpoint must answer 2xx; any other status raises. ``timeout_ms``
37-
bounds the whole request (default 10 seconds). ``max_record_size_bytes``
36+
The endpoint must answer 2xx; any other status raises. ``timeout_ms`` is a
37+
deadline for the whole request (connect, send, and response headers;
38+
default 10 seconds); on expiry the connection is shut down and
39+
``TimeoutError`` is raised. ``max_record_size_bytes``
3840
has no default because a generic endpoint has no known limit.
3941
"""
4042

packages/aws-durable-execution-sdk-python-insight/tests/test_http_exporter.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from __future__ import annotations
77

88
import json
9+
import socket
910
import threading
1011
import time
1112
from dataclasses import dataclass, field
@@ -222,3 +223,59 @@ def test_redirects_are_not_followed(http_capture: HttpCapture, status: int) -> N
222223
assert [r.path for r in http_capture.requests] == ["/insight"]
223224
assert http_capture.requests[0].method == "POST"
224225
assert http_capture.requests[0].body
226+
227+
228+
@pytest.fixture
229+
def trickle_server() -> Iterator[str]:
230+
"""Accept one request, then send the status line one byte every 200 ms.
231+
232+
Each byte arrives well inside any per-read socket timeout, so only a
233+
whole-request deadline can end the exchange early.
234+
"""
235+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
236+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
237+
listener.bind(("127.0.0.1", 0))
238+
listener.listen(1)
239+
listener.settimeout(5)
240+
stop = threading.Event()
241+
242+
def serve() -> None:
243+
try:
244+
conn, _ = listener.accept()
245+
except OSError:
246+
return
247+
with conn:
248+
conn.settimeout(5)
249+
try:
250+
conn.recv(65536) # the request; content is irrelevant
251+
for byte in b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n":
252+
if stop.is_set():
253+
return
254+
conn.sendall(bytes([byte]))
255+
time.sleep(0.2)
256+
except OSError:
257+
return
258+
259+
thread = threading.Thread(target=serve, daemon=True)
260+
thread.start()
261+
try:
262+
yield f"http://127.0.0.1:{listener.getsockname()[1]}"
263+
finally:
264+
stop.set()
265+
listener.close()
266+
thread.join(timeout=5)
267+
268+
269+
def test_timeout_is_a_whole_request_deadline(trickle_server: str) -> None:
270+
exporter = HttpExporter(url=trickle_server, timeout_ms=500)
271+
started = time.monotonic()
272+
with pytest.raises(TimeoutError, match=r"exceeded 0\.5s"):
273+
exporter.export(_record())
274+
elapsed = time.monotonic() - started
275+
# ~40 bytes at 200 ms each would take ~8 s without a deadline
276+
assert 0.4 <= elapsed < 2.0, elapsed
277+
278+
279+
def test_unsupported_url_scheme_is_rejected() -> None:
280+
with pytest.raises(ValueError, match="Unsupported URL"):
281+
HttpExporter(url="ftp://127.0.0.1/insight").export(_record())

0 commit comments

Comments
 (0)