2323class _FakeResponse :
2424 """Minimal stand-in for requests.Response used by safe_get."""
2525
26- def __init__ (self , status_code , headers = None ):
26+ def __init__ (self , status_code , headers = None , close_exc = None ):
2727 self .status_code = status_code
2828 self .headers = headers or {}
2929 self .closed = False
30+ self ._close_exc = close_exc
3031
3132 def close (self ):
3233 self .closed = True
34+ if self ._close_exc is not None :
35+ raise self ._close_exc
3336
3437
3538def _addrinfo (* ips ):
@@ -145,6 +148,8 @@ def resolve(host, _port):
145148 with self .assertRaises (SSRFError ):
146149 safe_get ("https://example.com/image.jpg" )
147150
151+ session .close .assert_called_once () # redirect hop must not leak its session
152+
148153 @patch ("runpod.serverless.utils.rp_ssrf._build_pinned_session" )
149154 @patch ("runpod.serverless.utils.rp_ssrf.resolve_and_validate" , return_value = ["93.184.216.34" ])
150155 def test_too_many_redirects (self , _mock_resolve , mock_session ):
@@ -167,6 +172,8 @@ def test_content_length_over_cap_rejected(self, _mock_resolve, mock_session):
167172 with self .assertRaises (SSRFError ):
168173 safe_get ("https://example.com/big.bin" , max_bytes = 1024 )
169174
175+ session .close .assert_called_once () # cap breach must close the pinned session
176+
170177 @patch ("runpod.serverless.utils.rp_ssrf._build_pinned_session" )
171178 @patch ("runpod.serverless.utils.rp_ssrf.resolve_and_validate" , return_value = ["93.184.216.34" ])
172179 def test_returns_response_on_success (self , _mock_resolve , mock_session ):
@@ -177,6 +184,52 @@ def test_returns_response_on_success(self, _mock_resolve, mock_session):
177184
178185 self .assertIs (safe_get ("https://example.com/ok.jpg" , max_bytes = 4096 ), response )
179186
187+ @patch ("runpod.serverless.utils.rp_ssrf._build_pinned_session" )
188+ @patch ("runpod.serverless.utils.rp_ssrf.resolve_and_validate" , return_value = ["93.184.216.34" ])
189+ def test_closing_response_closes_session (self , _mock_resolve , mock_session ):
190+ """The session outlives safe_get() only until the caller closes the response."""
191+ response = _FakeResponse (200 , {"Content-Length" : "500" })
192+ session = MagicMock ()
193+ session .get .return_value = response
194+ mock_session .return_value = session
195+
196+ result = safe_get ("https://example.com/ok.jpg" , max_bytes = 4096 )
197+ session .close .assert_not_called () # still open for the caller to stream
198+
199+ result .close ()
200+ self .assertTrue (response .closed ) # original close still runs
201+ session .close .assert_called_once () # and the session is torn down with it
202+
203+ @patch ("runpod.serverless.utils.rp_ssrf._build_pinned_session" )
204+ @patch ("runpod.serverless.utils.rp_ssrf.resolve_and_validate" , return_value = ["93.184.216.34" ])
205+ def test_request_error_closes_session (self , _mock_resolve , mock_session ):
206+ """A failed GET must tear down the session before the error propagates."""
207+ session = MagicMock ()
208+ session .get .side_effect = RequestException ("boom" )
209+ mock_session .return_value = session
210+
211+ with self .assertRaises (RequestException ):
212+ safe_get ("https://example.com/x" )
213+
214+ session .close .assert_called_once ()
215+
216+ @patch ("runpod.serverless.utils.rp_ssrf._build_pinned_session" )
217+ @patch ("runpod.serverless.utils.rp_ssrf.resolve_and_validate" , return_value = ["93.184.216.34" ])
218+ def test_session_close_error_does_not_mask_response_error (self , _mock_resolve , mock_session ):
219+ """Cleanup is best-effort: the caller's close error wins over a session-teardown error."""
220+ response = _FakeResponse (200 , {"Content-Length" : "500" }, close_exc = RuntimeError ("primary" ))
221+ session = MagicMock ()
222+ session .get .return_value = response
223+ session .close .side_effect = RuntimeError ("secondary pool teardown" )
224+ mock_session .return_value = session
225+
226+ result = safe_get ("https://example.com/ok.jpg" , max_bytes = 4096 )
227+ with self .assertRaises (RuntimeError ) as ctx :
228+ result .close ()
229+
230+ self .assertEqual (str (ctx .exception ), "primary" ) # response error surfaces
231+ session .close .assert_called_once () # teardown was still attempted
232+
180233
181234class TestIterContentCapped (unittest .TestCase ):
182235 """Streaming byte counter aborts past the cap even when headers lie."""
@@ -232,10 +285,11 @@ def test_dials_pinned_ip_and_preserves_host_header(self):
232285 thread .start ()
233286 try :
234287 # URL host is example.com (won't route to our server); pin to loopback.
235- session = _build_pinned_session ("127.0.0.1" )
236- response = session .get (f"http://example.com:{ port } /path" , timeout = 5 )
237- self .assertEqual (response .status_code , 200 )
238- self .assertEqual (response .content , b"ok" )
288+ with _build_pinned_session ("127.0.0.1" ) as session , session .get (
289+ f"http://example.com:{ port } /path" , timeout = 5
290+ ) as response :
291+ self .assertEqual (response .status_code , 200 )
292+ self .assertEqual (response .content , b"ok" )
239293 self .assertEqual (_RecordingHandler .received_host , f"example.com:{ port } " )
240294 finally :
241295 server .shutdown ()
0 commit comments