Skip to content

Commit 12bc88f

Browse files
committed
fix(traces): classify the export response without reading its body
A requests timeout bounds read inactivity, not the whole response, so a proxy answering 503 with a body that keeps dripping held the exporter's single flight open indefinitely. Stream the response, classify it from status and headers, and close it unread.
1 parent fbbbe38 commit 12bc88f

3 files changed

Lines changed: 76 additions & 0 deletions

File tree

posthog/test/tracing/test_transport.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import gzip
22
import json
3+
import threading
4+
import time
35
from datetime import datetime, timezone
6+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
from types import SimpleNamespace
58
from unittest import mock
69

@@ -199,3 +202,64 @@ def test_survives_a_throwing_headers_object(self):
199202
session.post.return_value = response
200203
outcome, _ = send(session=session)
201204
assert outcome == SendOutcome("retry-later", None)
205+
206+
207+
class _ChunkedHandler(BaseHTTPRequestHandler):
208+
def do_POST(self):
209+
self.rfile.read(int(self.headers.get("Content-Length", 0)))
210+
self.send_response(self.server.status)
211+
self.send_header("Transfer-Encoding", "chunked")
212+
self.end_headers()
213+
if self.server.status < 300:
214+
self.wfile.write(b"0\r\n\r\n")
215+
return
216+
# An error body that drips a chunk every 10 ms and never finishes.
217+
while not self.server.stop.is_set():
218+
try:
219+
self.wfile.write(b"1\r\nx\r\n")
220+
self.wfile.flush()
221+
except OSError:
222+
return
223+
time.sleep(0.01)
224+
225+
def log_message(self, *args):
226+
pass
227+
228+
229+
@pytest.fixture
230+
def local_server():
231+
servers = []
232+
233+
def start(status):
234+
server = ThreadingHTTPServer(("127.0.0.1", 0), _ChunkedHandler)
235+
server.status = status
236+
server.stop = threading.Event()
237+
thread = threading.Thread(target=server.serve_forever, daemon=True)
238+
thread.start()
239+
servers.append((server, thread))
240+
return "http://127.0.0.1:{}".format(server.server_port)
241+
242+
yield start
243+
for server, thread in servers:
244+
server.stop.set()
245+
server.shutdown()
246+
server.server_close()
247+
thread.join(2)
248+
249+
250+
class TestResponseBody:
251+
def test_closes_the_response_without_reading_the_body(self):
252+
_, session = send()
253+
assert session.post.call_args[1]["stream"] is True
254+
assert session.post.return_value.close.called
255+
256+
def test_does_not_wait_for_a_dripping_error_body(self, local_server):
257+
client = fake_client(host=local_server(503), timeout=0.5)
258+
started = time.monotonic()
259+
outcome = send_traces_batch(client, PAYLOAD)
260+
assert outcome == SendOutcome("retry-later", None)
261+
assert time.monotonic() - started < 2
262+
263+
def test_a_completed_response_is_still_ok(self, local_server):
264+
client = fake_client(host=local_server(200), timeout=0.5)
265+
assert send_traces_batch(client, PAYLOAD) == SendOutcome("ok")

posthog/tracing/_transport.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,21 @@ def send_traces_batch(client: Any, payload: dict) -> SendOutcome:
108108
"User-Agent": USER_AGENT,
109109
},
110110
timeout=timeout,
111+
stream=True,
111112
)
112113
except requests.exceptions.RequestException as e:
113114
log.debug("Span batch request failed: %s", e)
114115
return SendOutcome("retry-later")
116+
# Status and headers alone classify the response, so the body is never
117+
# read: the timeout bounds read inactivity, and a body that keeps dripping
118+
# would otherwise hold the exporter's single flight open indefinitely.
119+
try:
120+
return _classify(response)
121+
finally:
122+
response.close()
123+
115124

125+
def _classify(response: requests.Response) -> SendOutcome:
116126
status = response.status_code
117127
if status < 300:
118128
return OK

typings/requests/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Response:
88
text: str
99
headers: dict[str, str]
1010
def json(self) -> Any: ...
11+
def close(self) -> None: ...
1112

1213
class Session:
1314
def mount(self, prefix: str, adapter: adapters.HTTPAdapter) -> None: ...
@@ -19,6 +20,7 @@ class Session:
1920
data: str | bytes,
2021
headers: dict[str, str],
2122
timeout: int,
23+
stream: bool = ...,
2224
) -> Response: ...
2325
def get(
2426
self,

0 commit comments

Comments
 (0)