Skip to content

Commit b65e1d3

Browse files
committed
fix keepalive redirects issue
1 parent ecc6f5c commit b65e1d3

2 files changed

Lines changed: 217 additions & 37 deletions

File tree

shotgun_api3/shotgun.py

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from xmlrpc.client import Error, ProtocolError, ResponseError # noqa
7171

7272
from .lib.httplib2 import (
73+
DEFAULT_MAX_REDIRECTS,
7374
Http,
7475
HTTPConnectionWithTimeout,
7576
HTTPSConnectionWithTimeout,
@@ -227,6 +228,49 @@ class KeepaliveHTTPSConnection(_KeepaliveConnectionMixin, HTTPSConnectionWithTim
227228
"""
228229

229230

231+
KEEPALIVE_CONNECTION_TYPES = {
232+
"http": KeepaliveHTTPConnection,
233+
"https": KeepaliveHTTPSConnection,
234+
}
235+
236+
237+
class KeepaliveHttp(Http):
238+
"""
239+
httplib2 ``Http`` that routes every request through a keepalive-enabled
240+
connection class.
241+
242+
The scheme is resolved per call rather than once at construction because
243+
httplib2 follows redirects by calling ``self.request()`` again, without
244+
forwarding the ``connection_type`` argument it was given. Overriding
245+
``request()`` catches those recursive calls too, so a redirect to another
246+
authority -- or from http to https -- still gets keepalive. Doing it here
247+
also keeps the bundled httplib2 unmodified.
248+
"""
249+
250+
def request(
251+
self,
252+
uri: str,
253+
method: str = "GET",
254+
body=None,
255+
headers: Optional[Dict[str, Any]] = None,
256+
redirections: int = DEFAULT_MAX_REDIRECTS,
257+
connection_type=None,
258+
):
259+
if connection_type is None:
260+
scheme = urllib.parse.urlsplit(uri).scheme.lower()
261+
# An unknown scheme is left as None so httplib2 raises its own
262+
# error rather than being handed a connection class it cannot use.
263+
connection_type = KEEPALIVE_CONNECTION_TYPES.get(scheme)
264+
return super().request(
265+
uri,
266+
method=method,
267+
body=body,
268+
headers=headers,
269+
redirections=redirections,
270+
connection_type=connection_type,
271+
)
272+
273+
230274
# ----------------------------------------------------------------------------
231275
# Errors
232276

@@ -519,11 +563,15 @@ def __init__(self, sg: "Shotgun"):
519563
# idle TCP session without sending FIN or RST; reusing such a socket
520564
# blocks in getresponse() until the socket timeout expires. 60 seconds
521565
# sits below the idle timeouts commonly configured on that hardware.
522-
# Set to None or 0 to reuse connections regardless of idle time.
566+
# Set to 0 (or None) to reuse connections regardless of idle time,
567+
# restoring the behaviour of releases before this one.
523568
#
524569
# sg = Shotgun(site_name, script_name, script_key)
525570
# sg.config.max_connection_idle_secs = 30
526571
#
572+
# Or by setting the ``SHOTGUN_API_MAX_CONNECTION_IDLE`` environment
573+
# variable. In the case that the environment variable is already set,
574+
# setting the property on the config will override it.
527575
self.max_connection_idle_secs: Optional[float] = 60
528576
self.api_ver = "api3"
529577
self.convert_datetimes_to_utc = True
@@ -764,6 +812,21 @@ def __init__(
764812
"got '%s'." % self.config.rpc_attempt_interval
765813
)
766814

815+
max_idle = os.environ.get("SHOTGUN_API_MAX_CONNECTION_IDLE")
816+
if max_idle is not None:
817+
try:
818+
self.config.max_connection_idle_secs = int(max_idle)
819+
except ValueError:
820+
raise ValueError(
821+
"Invalid value '%s' found in environment variable "
822+
"SHOTGUN_API_MAX_CONNECTION_IDLE, must be int." % max_idle
823+
)
824+
if self.config.max_connection_idle_secs < 0:
825+
raise ValueError(
826+
"Value of SHOTGUN_API_MAX_CONNECTION_IDLE must be positive, "
827+
"got '%s'." % self.config.max_connection_idle_secs
828+
)
829+
767830
global SHOTGUN_API_DISABLE_ENTITY_OPTIMIZATION
768831
if (
769832
os.environ.get("SHOTGUN_API_DISABLE_ENTITY_OPTIMIZATION", "0")
@@ -4116,21 +4179,9 @@ def _http_request(
41164179
LOG.debug("Request body is %s" % body)
41174180

41184181
conn = self._get_connection()
4119-
# connection_type is httplib2's injection point for a custom connection
4120-
# class, and is only consulted when a new connection is created. Using
4121-
# it keeps the keepalive setup out of the bundled httplib2. The scheme
4122-
# here is the one `url` was built from just above.
4123-
if self.config.scheme == "https":
4124-
connection_type = KeepaliveHTTPSConnection
4125-
else:
4126-
connection_type = KeepaliveHTTPConnection
4127-
resp, content = conn.request(
4128-
url,
4129-
method=verb,
4130-
body=body,
4131-
headers=headers,
4132-
connection_type=connection_type,
4133-
)
4182+
# KeepaliveHttp picks the keepalive-enabled connection class itself, for
4183+
# this request and for any redirect it follows.
4184+
resp, content = conn.request(url, method=verb, body=body, headers=headers)
41344185
# Record the idle-clock start only once the request has completed. A
41354186
# request that raised must not refresh it, or the next call would reuse
41364187
# a connection we have no evidence is alive.
@@ -4365,6 +4416,9 @@ def _get_connection(self) -> Http:
43654416
"closing it and reconnecting."
43664417
% self.config.max_connection_idle_secs
43674418
)
4419+
# _close_connection() resets self._connection to None, so this
4420+
# falls through to build a replacement below. httplib2 opens the
4421+
# new socket lazily on the next request.
43684422
self._close_connection()
43694423
else:
43704424
return self._connection
@@ -4377,13 +4431,13 @@ def _get_connection(self) -> Http:
43774431
proxy_user=self.config.proxy_user,
43784432
proxy_pass=self.config.proxy_pass,
43794433
)
4380-
self._connection = Http(
4434+
self._connection = KeepaliveHttp(
43814435
timeout=self.config.timeout_secs,
43824436
ca_certs=self.__ca_certs,
43834437
proxy_info=pi,
43844438
)
43854439
else:
4386-
self._connection = Http(
4440+
self._connection = KeepaliveHttp(
43874441
timeout=self.config.timeout_secs,
43884442
ca_certs=self.__ca_certs,
43894443
proxy_info=None,

tests/test_unit.py

Lines changed: 145 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import os
1414
import socket
1515
import ssl
16+
import threading
1617
import unittest
1718
from unittest import mock
1819
import urllib.request
@@ -893,7 +894,7 @@ def setUp(self):
893894
self.addCleanup(clock_patcher.stop)
894895

895896
http_patcher = mock.patch(
896-
"shotgun_api3.shotgun.Http", side_effect=self._make_connection
897+
"shotgun_api3.shotgun.KeepaliveHttp", side_effect=self._make_connection
897898
)
898899
http_patcher.start()
899900
self.addCleanup(http_patcher.stop)
@@ -1186,37 +1187,48 @@ def reject_keepalive(level, option, value):
11861187

11871188
class TestKeepaliveConnectionType(unittest.TestCase):
11881189
"""
1189-
Test that the keepalive-enabled connection classes are injected into
1190-
httplib2 via its connection_type parameter, so the bundled httplib2 needs
1191-
no modification (SG-44724).
1190+
Test that KeepaliveHttp injects the keepalive-enabled connection classes
1191+
into httplib2, so the bundled httplib2 needs no modification (SG-44724).
11921192
"""
11931193

1194-
def _connection_type_used(self, url):
1195-
sg = api.Shotgun(url, "script_name", "api_key", connect=False)
1196-
conn = mock.MagicMock()
1197-
response = mock.MagicMock()
1198-
response.status = 200
1199-
response.reason = "OK"
1200-
response.items.return_value = []
1201-
conn.request.return_value = (response, "{}")
1202-
1203-
with mock.patch.object(sg, "_get_connection", return_value=conn):
1204-
sg._http_request("GET", "/path", None, {})
1205-
1206-
return conn.request.call_args[1]["connection_type"]
1194+
def _injected_for(self, uri):
1195+
"""Return the connection_type KeepaliveHttp hands to httplib2."""
1196+
http = shotgun.KeepaliveHttp()
1197+
with mock.patch.object(
1198+
shotgun.Http, "request", return_value=(mock.MagicMock(), b"")
1199+
) as base:
1200+
http.request(uri)
1201+
return base.call_args[1]["connection_type"]
12071202

12081203
def test_https_uses_keepalive_connection(self):
12091204
self.assertIs(
1210-
self._connection_type_used("https://server_path"),
1205+
self._injected_for("https://server_path/x"),
12111206
shotgun.KeepaliveHTTPSConnection,
12121207
)
12131208

12141209
def test_http_uses_keepalive_connection(self):
12151210
self.assertIs(
1216-
self._connection_type_used("http://server_path"),
1211+
self._injected_for("http://server_path/x"),
12171212
shotgun.KeepaliveHTTPConnection,
12181213
)
12191214

1215+
def test_unknown_scheme_is_left_to_httplib2(self):
1216+
"""httplib2 should raise its own error rather than be handed a class."""
1217+
self.assertIsNone(self._injected_for("ftp://server_path/x"))
1218+
1219+
def test_explicit_connection_type_is_respected(self):
1220+
http = shotgun.KeepaliveHttp()
1221+
sentinel = shotgun.KeepaliveHTTPConnection
1222+
with mock.patch.object(
1223+
shotgun.Http, "request", return_value=(mock.MagicMock(), b"")
1224+
) as base:
1225+
http.request("https://server_path/x", connection_type=sentinel)
1226+
self.assertIs(base.call_args[1]["connection_type"], sentinel)
1227+
1228+
def test_shotgun_uses_keepalive_http(self):
1229+
sg = api.Shotgun("https://server_path", "script_name", "api_key", connect=False)
1230+
self.assertIsInstance(sg._get_connection(), shotgun.KeepaliveHttp)
1231+
12201232
def test_connection_classes_are_httplib2_subclasses(self):
12211233
"""httplib2 branches on the class to pick constructor arguments."""
12221234
self.assertTrue(
@@ -1233,5 +1245,119 @@ def test_connection_classes_are_httplib2_subclasses(self):
12331245
)
12341246

12351247

1248+
class TestMaxConnectionIdleEnvVar(unittest.TestCase):
1249+
"""
1250+
SHOTGUN_API_MAX_CONNECTION_IDLE lets operators tune or disable the idle
1251+
expiry without code changes (SG-44724).
1252+
"""
1253+
1254+
def _make(self):
1255+
return api.Shotgun(
1256+
"http://server_path", "script_name", "api_key", connect=False
1257+
)
1258+
1259+
def test_default_is_60(self):
1260+
with mock.patch.dict(os.environ, {}, clear=False):
1261+
os.environ.pop("SHOTGUN_API_MAX_CONNECTION_IDLE", None)
1262+
self.assertEqual(self._make().config.max_connection_idle_secs, 60)
1263+
1264+
def test_env_var_overrides_default(self):
1265+
with mock.patch.dict(os.environ, {"SHOTGUN_API_MAX_CONNECTION_IDLE": "15"}):
1266+
self.assertEqual(self._make().config.max_connection_idle_secs, 15)
1267+
1268+
def test_env_var_zero_disables_expiry(self):
1269+
with mock.patch.dict(os.environ, {"SHOTGUN_API_MAX_CONNECTION_IDLE": "0"}):
1270+
sg = self._make()
1271+
self.assertEqual(sg.config.max_connection_idle_secs, 0)
1272+
self.assertFalse(sg._is_connection_stale())
1273+
1274+
def test_non_integer_env_var_raises(self):
1275+
with mock.patch.dict(os.environ, {"SHOTGUN_API_MAX_CONNECTION_IDLE": "banana"}):
1276+
self.assertRaises(ValueError, self._make)
1277+
1278+
def test_negative_env_var_raises(self):
1279+
with mock.patch.dict(os.environ, {"SHOTGUN_API_MAX_CONNECTION_IDLE": "-5"}):
1280+
self.assertRaises(ValueError, self._make)
1281+
1282+
1283+
class TestKeepaliveAcrossRedirects(unittest.TestCase):
1284+
"""
1285+
httplib2 follows a redirect by calling self.request() again without
1286+
forwarding connection_type, so injecting it at the call site would lose
1287+
keepalive on the redirected connection. KeepaliveHttp overrides request(),
1288+
which catches those recursive calls too (SG-44724).
1289+
1290+
Uses two loopback servers; no external network.
1291+
"""
1292+
1293+
def setUp(self):
1294+
self.stop = threading.Event()
1295+
self.addCleanup(self.stop.set)
1296+
self.target_port = self._serve(self._target)
1297+
self.redirect_port = self._serve(self._redirect)
1298+
1299+
def _serve(self, handler):
1300+
listener = socket.socket()
1301+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1302+
listener.bind(("127.0.0.1", 0))
1303+
listener.listen(4)
1304+
port = listener.getsockname()[1]
1305+
self.addCleanup(listener.close)
1306+
1307+
def loop():
1308+
while not self.stop.is_set():
1309+
try:
1310+
conn, _ = listener.accept()
1311+
except OSError:
1312+
return
1313+
try:
1314+
conn.settimeout(5)
1315+
conn.recv(4096)
1316+
conn.sendall(handler())
1317+
except OSError:
1318+
pass
1319+
finally:
1320+
conn.close()
1321+
1322+
thread = threading.Thread(target=loop)
1323+
thread.daemon = True
1324+
thread.start()
1325+
return port
1326+
1327+
def _target(self):
1328+
return (
1329+
b"HTTP/1.1 200 OK\r\n"
1330+
b"Content-Length: 2\r\n"
1331+
b"Connection: close\r\n"
1332+
b"\r\n"
1333+
b"{}"
1334+
)
1335+
1336+
def _redirect(self):
1337+
# 302 to a different authority, which forces httplib2 to build a second
1338+
# connection -- the one that used to miss keepalive.
1339+
return (
1340+
"HTTP/1.1 302 Found\r\n"
1341+
"Location: http://127.0.0.1:%d/target\r\n"
1342+
"Content-Length: 0\r\n"
1343+
"Connection: close\r\n"
1344+
"\r\n" % self.target_port
1345+
).encode("ascii")
1346+
1347+
def test_redirected_connection_is_keepalive_enabled(self):
1348+
http = shotgun.KeepaliveHttp()
1349+
response, _ = http.request(
1350+
"http://127.0.0.1:%d/start" % self.redirect_port, method="GET"
1351+
)
1352+
1353+
self.assertEqual(response["status"], "200")
1354+
# Both the original and the redirect target are cached; neither may be a
1355+
# plain httplib2 connection.
1356+
cached = list(http.connections.items())
1357+
self.assertEqual(len(cached), 2, cached)
1358+
for key, conn in cached:
1359+
self.assertIsInstance(conn, shotgun.KeepaliveHTTPConnection, key)
1360+
1361+
12361362
if __name__ == "__main__":
12371363
unittest.main()

0 commit comments

Comments
 (0)