22import json
33import threading
44import time
5- from datetime import datetime , timezone
5+ from datetime import datetime , timedelta , timezone
6+ from email .utils import format_datetime
67from http .server import BaseHTTPRequestHandler , ThreadingHTTPServer
78from types import SimpleNamespace
89from unittest import mock
@@ -149,11 +150,10 @@ def test_reads_retry_after_delta_seconds(self):
149150 assert outcome == SendOutcome ("retry-later" , 120.0 )
150151
151152 def test_reads_retry_after_http_date (self ):
152- outcome , _ = send (
153- session = mock_session (503 , {"Retry-After" : "Wed, 21 Oct 2099 07:28:00 GMT" })
154- )
153+ when = format_datetime (datetime .now (timezone .utc ) + timedelta (seconds = 120 ))
154+ outcome , _ = send (session = mock_session (503 , {"Retry-After" : when }))
155155 assert outcome .kind == "retry-later"
156- assert outcome .retry_after is not None and outcome .retry_after > 0
156+ assert outcome .retry_after is not None and 100 < outcome .retry_after <= 120
157157
158158
159159NOW = datetime (2026 , 9 , 10 , 12 , 0 , 0 , tzinfo = timezone .utc )
@@ -195,13 +195,25 @@ def test_ignores_an_unparseable_retry_after(self):
195195 outcome , _ = send (session = mock_session (429 , {"Retry-After" : "10 minutes" }))
196196 assert outcome == SendOutcome ("retry-later" , None )
197197
198- def test_survives_a_throwing_headers_object (self ):
199- response = mock .Mock (status_code = 503 )
200- response .headers .get .side_effect = RuntimeError ("no headers" )
201- session = mock .Mock ()
202- session .post .return_value = response
203- outcome , _ = send (session = session )
204- assert outcome == SendOutcome ("retry-later" , None )
198+
199+ class _SizedHandler (BaseHTTPRequestHandler ):
200+ protocol_version = "HTTP/1.1"
201+
202+ def setup (self ):
203+ super ().setup ()
204+ self .server .connections += 1
205+
206+ def do_POST (self ):
207+ self .rfile .read (int (self .headers .get ("Content-Length" , 0 )))
208+ body = self .server .body
209+ self .send_response (self .server .status )
210+ self .send_header ("Content-Type" , "application/json" )
211+ self .send_header ("Content-Length" , str (len (body )))
212+ self .end_headers ()
213+ self .wfile .write (body )
214+
215+ def log_message (self , * args ):
216+ pass
205217
206218
207219class _ChunkedHandler (BaseHTTPRequestHandler ):
@@ -230,16 +242,19 @@ def log_message(self, *args):
230242def local_server ():
231243 servers = []
232244
233- def start (status ):
234- server = ThreadingHTTPServer (("127.0.0.1" , 0 ), _ChunkedHandler )
245+ def start (status , body = None ):
246+ handler = _ChunkedHandler if body is None else _SizedHandler
247+ server = ThreadingHTTPServer (("127.0.0.1" , 0 ), handler )
235248 server .status = status
249+ server .body = body
250+ server .connections = 0
236251 server .stop = threading .Event ()
237252 thread = threading .Thread (target = server .serve_forever , daemon = True )
238253 thread .start ()
239254 servers .append ((server , thread ))
240255 return "http://127.0.0.1:{}" .format (server .server_port )
241256
242- yield start
257+ yield servers , start
243258 for server , thread in servers :
244259 server .stop .set ()
245260 server .shutdown ()
@@ -248,18 +263,48 @@ def start(status):
248263
249264
250265class TestResponseBody :
251- def test_closes_the_response_without_reading_the_body (self ):
266+ def test_closes_the_response_without_reading_an_unsized_body (self ):
252267 _ , session = send ()
253268 assert session .post .call_args [1 ]["stream" ] is True
254269 assert session .post .return_value .close .called
255270
256271 def test_does_not_wait_for_a_dripping_error_body (self , local_server ):
257- client = fake_client (host = local_server (503 ), timeout = 0.5 )
272+ _ , start = local_server
273+ client = fake_client (host = start (503 ), timeout = 0.5 )
258274 started = time .monotonic ()
259275 outcome = send_traces_batch (client , PAYLOAD )
260276 assert outcome == SendOutcome ("retry-later" , None )
261277 assert time .monotonic () - started < 2
262278
263279 def test_a_completed_response_is_still_ok (self , local_server ):
264- client = fake_client (host = local_server (200 ), timeout = 0.5 )
280+ _ , start = local_server
281+ client = fake_client (host = start (200 ), timeout = 0.5 )
265282 assert send_traces_batch (client , PAYLOAD ) == SendOutcome ("ok" )
283+
284+ def test_drains_a_small_sized_body_so_the_connection_is_reused (self , local_server ):
285+ servers , start = local_server
286+ client = fake_client (host = start (200 , body = b"{}" ), timeout = 2 )
287+ for _ in range (5 ):
288+ assert send_traces_batch (client , PAYLOAD ) == SendOutcome ("ok" )
289+ assert servers [0 ][0 ].connections == 1
290+
291+ def test_leaves_a_large_sized_body_unread (self ):
292+ response = mock .Mock (
293+ status_code = 200 , headers = {"Content-Length" : str (64 * 1024 + 1 )}
294+ )
295+ type(response ).text = mock .PropertyMock (side_effect = AssertionError ("read" ))
296+ session = mock .Mock ()
297+ session .post .return_value = response
298+ assert send (session = session )[0 ] == SendOutcome ("ok" )
299+ assert response .close .called
300+
301+ def test_logs_the_server_error_body_on_a_fatal_status (self , caplog ):
302+ body = '{"error": "invalid api key"}'
303+ response = mock .Mock (
304+ status_code = 401 , headers = {"Content-Length" : str (len (body ))}, text = body
305+ )
306+ session = mock .Mock ()
307+ session .post .return_value = response
308+ with caplog .at_level ("ERROR" , logger = "posthog" ):
309+ assert send (session = session )[0 ] == SendOutcome ("fatal" )
310+ assert 'HTTP 401: {"error": "invalid api key"}' in caplog .text
0 commit comments