1313import os
1414import socket
1515import ssl
16+ import threading
1617import unittest
1718from unittest import mock
1819import 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
11871188class 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+
12361362if __name__ == "__main__" :
12371363 unittest .main ()
0 commit comments