Skip to content

Commit c3e5c65

Browse files
committed
fix: response leak when using caching adapter and streaming
1 parent 34564d8 commit c3e5c65

File tree

3 files changed

+48
-8
lines changed

3 files changed

+48
-8
lines changed

cachecontrol/adapter.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import functools
77
import types
8+
import weakref
89
import zlib
910
from typing import TYPE_CHECKING, Any, Collection, Mapping
1011

@@ -128,19 +129,25 @@ def build_response( # type: ignore[override]
128129
response._fp = CallbackFileWrapper( # type: ignore[assignment]
129130
response._fp, # type: ignore[arg-type]
130131
functools.partial(
131-
self.controller.cache_response, request, response
132+
self.controller.cache_response, request, weakref.ref(response)
132133
),
133134
)
134135
if response.chunked:
135-
super_update_chunk_length = response._update_chunk_length
136+
super_update_chunk_length = response.__class__._update_chunk_length
136137

137-
def _update_chunk_length(self: HTTPResponse) -> None:
138-
super_update_chunk_length()
138+
def _update_chunk_length(
139+
weak_self: weakref.ReferenceType[HTTPResponse],
140+
) -> None:
141+
self = weak_self()
142+
if self is None:
143+
return
144+
145+
super_update_chunk_length(self)
139146
if self.chunk_left == 0:
140147
self._fp._close() # type: ignore[union-attr]
141148

142-
response._update_chunk_length = types.MethodType( # type: ignore[method-assign]
143-
_update_chunk_length, response
149+
response._update_chunk_length = functools.partial( # type: ignore[method-assign]
150+
_update_chunk_length, weakref.ref(response)
144151
)
145152

146153
resp: Response = super().build_response(request, response)

cachecontrol/controller.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import logging
1313
import re
1414
import time
15+
import weakref
1516
from email.utils import parsedate_tz
1617
from typing import TYPE_CHECKING, Collection, Mapping
1718

@@ -323,7 +324,7 @@ def _cache_set(
323324
def cache_response(
324325
self,
325326
request: PreparedRequest,
326-
response: HTTPResponse,
327+
response_or_ref: HTTPResponse | weakref.ReferenceType[HTTPResponse],
327328
body: bytes | None = None,
328329
status_codes: Collection[int] | None = None,
329330
) -> None:
@@ -332,6 +333,16 @@ def cache_response(
332333
333334
This assumes a requests Response object.
334335
"""
336+
if isinstance(response_or_ref, weakref.ReferenceType):
337+
response = response_or_ref()
338+
if response is None:
339+
# The weakref can be None only in case the user used streamed request
340+
# and did not consume or close it, and holds no reference to requests.Response.
341+
# In such case, we don't want to cache the response.
342+
return
343+
else:
344+
response = response_or_ref
345+
335346
# From httplib2: Don't cache 206's since we aren't going to
336347
# handle byte range requests
337348
cacheable_status_codes = status_codes or self.cacheable_status_codes

tests/test_adapter.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
import gc
6+
import weakref
57
from unittest import mock
6-
import pytest
78

9+
import pytest
810
from requests import Session
11+
912
from cachecontrol.adapter import CacheControlAdapter
1013
from cachecontrol.cache import DictCache
1114
from cachecontrol.wrapper import CacheControl
@@ -65,3 +68,22 @@ def test_close(self):
6568

6669
sess.close()
6770
assert cache.close.called
71+
72+
def test_do_not_leak_response(self, url, sess):
73+
resp = sess.get(url + "stream", stream=True)
74+
resp.raise_for_status()
75+
r1_weak = weakref.ref(resp.raw)
76+
77+
# This is a mis-use of requests, becase we should either consume
78+
# the body, or call .close().
79+
# But requests without cachecontrol handle this anyway, because
80+
# urllib3.response.HTTPResponse has a __del__ finalizer on it that closes it
81+
# once there are no more references to it.
82+
# We should not break this.
83+
84+
resp = None
85+
# Below this point, it should be closed because there are no more references
86+
# to the session response.
87+
88+
r1 = r1_weak()
89+
assert r1 is None or r1.closed

0 commit comments

Comments
 (0)