Skip to content

Commit b5f228f

Browse files
authored
fix: Setting a default timeout on all HTTP connections (#397)
* Setting a default timeout on all HTTP connections * Refactored and cleaned up tests * Further cleaning up the tests * Cleaning up tests based on feedback
1 parent 00cf1d3 commit b5f228f

11 files changed

+149
-71
lines changed

firebase_admin/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME):
4949
Google Application Default Credentials are used.
5050
options: A dictionary of configuration options (optional). Supported options include
5151
``databaseURL``, ``storageBucket``, ``projectId``, ``databaseAuthVariableOverride``,
52-
``serviceAccountId`` and ``httpTimeout``. If ``httpTimeout`` is not set, HTTP
53-
connections initiated by client modules such as ``db`` will not time out.
52+
``serviceAccountId`` and ``httpTimeout``. If ``httpTimeout`` is not set, the SDK
53+
uses a default timeout of 120 seconds.
5454
name: Name of the app (optional).
5555
Returns:
5656
App: A newly initialized instance of App.

firebase_admin/_http_client.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
raise_on_status=False, backoff_factor=0.5)
3333

3434

35+
DEFAULT_TIMEOUT_SECONDS = 120
36+
37+
3538
class HttpClient:
3639
"""Base HTTP client used to make HTTP calls.
3740
@@ -41,7 +44,7 @@ class HttpClient:
4144

4245
def __init__(
4346
self, credential=None, session=None, base_url='', headers=None,
44-
retries=DEFAULT_RETRY_CONFIG):
47+
retries=DEFAULT_RETRY_CONFIG, timeout=DEFAULT_TIMEOUT_SECONDS):
4548
"""Creates a new HttpClient instance from the provided arguments.
4649
4750
If a credential is provided, initializes a new HTTP session authorized with it. If neither
@@ -55,6 +58,8 @@ def __init__(
5558
retries: A urllib retry configuration. Default settings would retry once for low-level
5659
connection and socket read errors, and up to 4 times for HTTP 500 and 503 errors.
5760
Pass a False value to disable retries (optional).
61+
timeout: HTTP timeout in seconds. Defaults to 120 seconds when not specified. Set to
62+
None to disable timeouts (optional).
5863
"""
5964
if credential:
6065
self._session = transport.requests.AuthorizedSession(credential)
@@ -69,6 +74,7 @@ def __init__(
6974
self._session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries))
7075
self._session.mount('https://', requests.adapters.HTTPAdapter(max_retries=retries))
7176
self._base_url = base_url
77+
self._timeout = timeout
7278

7379
@property
7480
def session(self):
@@ -78,6 +84,10 @@ def session(self):
7884
def base_url(self):
7985
return self._base_url
8086

87+
@property
88+
def timeout(self):
89+
return self._timeout
90+
8191
def parse_body(self, resp):
8292
raise NotImplementedError
8393

@@ -93,15 +103,17 @@ class call this method to send HTTP requests out. Refer to
93103
method: HTTP method name as a string (e.g. get, post).
94104
url: URL of the remote endpoint.
95105
kwargs: An additional set of keyword arguments to be passed into the requests API
96-
(e.g. json, params).
106+
(e.g. json, params, timeout).
97107
98108
Returns:
99109
Response: An HTTP response object.
100110
101111
Raises:
102112
RequestException: Any requests exceptions encountered while making the HTTP call.
103113
"""
104-
resp = self._session.request(method, self._base_url + url, **kwargs)
114+
if 'timeout' not in kwargs:
115+
kwargs['timeout'] = self.timeout
116+
resp = self._session.request(method, self.base_url + url, **kwargs)
105117
resp.raise_for_status()
106118
return resp
107119

firebase_admin/db.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -775,7 +775,7 @@ def __init__(self, app):
775775
self._auth_override = json.dumps(auth_override, separators=(',', ':'))
776776
else:
777777
self._auth_override = None
778-
self._timeout = app.options.get('httpTimeout')
778+
self._timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
779779
self._clients = {}
780780

781781
emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR)
@@ -900,14 +900,13 @@ def __init__(self, credential, base_url, timeout, params=None):
900900
credential: A Google credential that can be used to authenticate requests.
901901
base_url: A URL prefix to be added to all outgoing requests. This is typically the
902902
Firebase Realtime Database URL.
903-
timeout: HTTP request timeout in seconds. If not set connections will never
903+
timeout: HTTP request timeout in seconds. If set to None connections will never
904904
timeout, which is the default behavior of the underlying requests library.
905905
params: Dict of query parameters to add to all outgoing requests.
906906
"""
907-
_http_client.JsonHttpClient.__init__(
908-
self, credential=credential, base_url=base_url, headers={'User-Agent': _USER_AGENT})
909-
self.credential = credential
910-
self.timeout = timeout
907+
super().__init__(
908+
credential=credential, base_url=base_url,
909+
timeout=timeout, headers={'User-Agent': _USER_AGENT})
911910
self.params = params if params else {}
912911

913912
def request(self, method, url, **kwargs):
@@ -937,8 +936,6 @@ def request(self, method, url, **kwargs):
937936
query = extra_params
938937
kwargs['params'] = query
939938

940-
if self.timeout:
941-
kwargs['timeout'] = self.timeout
942939
try:
943940
return super(_Client, self).request(method, url, **kwargs)
944941
except requests.exceptions.RequestException as error:

firebase_admin/messaging.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,8 +330,9 @@ def __init__(self, app):
330330
'X-GOOG-API-FORMAT-VERSION': '2',
331331
'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__),
332332
}
333-
self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential())
334-
self._timeout = app.options.get('httpTimeout')
333+
timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
334+
self._client = _http_client.JsonHttpClient(
335+
credential=app.credential.get_credential(), timeout=timeout)
335336
self._transport = _auth.authorized_http(app.credential.get_credential())
336337

337338
@classmethod
@@ -348,8 +349,7 @@ def send(self, message, dry_run=False):
348349
'post',
349350
url=self._fcm_url,
350351
headers=self._fcm_headers,
351-
json=data,
352-
timeout=self._timeout
352+
json=data
353353
)
354354
except requests.exceptions.RequestException as error:
355355
raise self._handle_fcm_error(error)
@@ -416,8 +416,7 @@ def make_topic_management_request(self, tokens, topic, operation):
416416
'post',
417417
url=url,
418418
json=data,
419-
headers=_MessagingService.IID_HEADERS,
420-
timeout=self._timeout
419+
headers=_MessagingService.IID_HEADERS
421420
)
422421
except requests.exceptions.RequestException as error:
423422
raise self._handle_iid_error(error)

firebase_admin/project_management.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -478,11 +478,12 @@ def __init__(self, app):
478478
'the GOOGLE_CLOUD_PROJECT environment variable.')
479479
self._project_id = project_id
480480
version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__)
481+
timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
481482
self._client = _http_client.JsonHttpClient(
482483
credential=app.credential.get_credential(),
483484
base_url=_ProjectManagementService.BASE_URL,
484-
headers={'X-Client-Version': version_header})
485-
self._timeout = app.options.get('httpTimeout')
485+
headers={'X-Client-Version': version_header},
486+
timeout=timeout)
486487

487488
def get_android_app_metadata(self, app_id):
488489
return self._get_app_metadata(
@@ -658,7 +659,6 @@ def _make_request(self, method, url, json=None):
658659

659660
def _body_and_response(self, method, url, json=None):
660661
try:
661-
return self._client.body_and_response(
662-
method=method, url=url, json=json, timeout=self._timeout)
662+
return self._client.body_and_response(method=method, url=url, json=json)
663663
except requests.exceptions.RequestException as error:
664664
raise _utils.handle_platform_error_from_requests(error)

tests/test_db.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import firebase_admin
2424
from firebase_admin import db
2525
from firebase_admin import exceptions
26+
from firebase_admin import _http_client
2627
from firebase_admin import _sseclient
2728
from tests import testutils
2829

@@ -731,15 +732,8 @@ def test_parse_db_url_errors(self, url, emulator_host):
731732
def test_valid_db_url(self, url):
732733
firebase_admin.initialize_app(testutils.MockCredential(), {'databaseURL' : url})
733734
ref = db.reference()
734-
recorder = []
735-
adapter = MockAdapter('{}', 200, recorder)
736-
ref._client.session.mount(url, adapter)
737735
assert ref._client.base_url == 'https://test.firebaseio.com'
738736
assert 'auth_variable_override' not in ref._client.params
739-
assert ref._client.timeout is None
740-
assert ref.get() == {}
741-
assert len(recorder) == 1
742-
assert recorder[0]._extra_kwargs.get('timeout') is None
743737

744738
@pytest.mark.parametrize('url', [
745739
None, '', 'foo', 'http://test.firebaseio.com', 'https://google.com',
@@ -761,15 +755,13 @@ def test_multi_db_support(self):
761755
ref = db.reference()
762756
assert ref._client.base_url == default_url
763757
assert 'auth_variable_override' not in ref._client.params
764-
assert ref._client.timeout is None
765758
assert ref._client is db.reference()._client
766759
assert ref._client is db.reference(url=default_url)._client
767760

768761
other_url = 'https://other.firebaseio.com'
769762
other_ref = db.reference(url=other_url)
770763
assert other_ref._client.base_url == other_url
771764
assert 'auth_variable_override' not in ref._client.params
772-
assert other_ref._client.timeout is None
773765
assert other_ref._client is db.reference(url=other_url)._client
774766
assert other_ref._client is db.reference(url=other_url + '/')._client
775767

@@ -782,7 +774,6 @@ def test_valid_auth_override(self, override):
782774
default_ref = db.reference()
783775
other_ref = db.reference(url='https://other.firebaseio.com')
784776
for ref in [default_ref, other_ref]:
785-
assert ref._client.timeout is None
786777
if override == {}:
787778
assert 'auth_variable_override' not in ref._client.params
788779
else:
@@ -804,22 +795,22 @@ def test_invalid_auth_override(self, override):
804795
with pytest.raises(ValueError):
805796
db.reference(app=other_app, url='https://other.firebaseio.com')
806797

807-
def test_http_timeout(self):
798+
@pytest.mark.parametrize('options, timeout', [
799+
({'httpTimeout': 4}, 4),
800+
({'httpTimeout': None}, None),
801+
({}, _http_client.DEFAULT_TIMEOUT_SECONDS),
802+
])
803+
def test_http_timeout(self, options, timeout):
808804
test_url = 'https://test.firebaseio.com'
809-
firebase_admin.initialize_app(testutils.MockCredential(), {
805+
all_options = {
810806
'databaseURL' : test_url,
811-
'httpTimeout': 60
812-
})
807+
}
808+
all_options.update(options)
809+
firebase_admin.initialize_app(testutils.MockCredential(), all_options)
813810
default_ref = db.reference()
814811
other_ref = db.reference(url='https://other.firebaseio.com')
815812
for ref in [default_ref, other_ref]:
816-
recorder = []
817-
adapter = MockAdapter('{}', 200, recorder)
818-
ref._client.session.mount(ref._client.base_url, adapter)
819-
assert ref._client.timeout == 60
820-
assert ref.get() == {}
821-
assert len(recorder) == 1
822-
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(60, 0.001)
813+
self._check_timeout(ref, timeout)
823814

824815
def test_app_delete(self):
825816
app = firebase_admin.initialize_app(
@@ -841,6 +832,18 @@ def test_user_agent_format(self):
841832
firebase_admin.__version__, sys.version_info.major, sys.version_info.minor)
842833
assert db._USER_AGENT == expected
843834

835+
def _check_timeout(self, ref, timeout):
836+
assert ref._client.timeout == timeout
837+
recorder = []
838+
adapter = MockAdapter('{}', 200, recorder)
839+
ref._client.session.mount(ref._client.base_url, adapter)
840+
assert ref.get() == {}
841+
assert len(recorder) == 1
842+
if timeout is None:
843+
assert recorder[0]._extra_kwargs['timeout'] is None
844+
else:
845+
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001)
846+
844847

845848
@pytest.fixture(params=['foo', '$key', '$value'])
846849
def initquery(request):

tests/test_http_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,24 @@ def test_credential():
7474
assert recorder[0].url == _TEST_URL
7575
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'
7676

77+
@pytest.mark.parametrize('options, timeout', [
78+
({}, _http_client.DEFAULT_TIMEOUT_SECONDS),
79+
({'timeout': 7}, 7),
80+
({'timeout': 0}, 0),
81+
({'timeout': None}, None),
82+
])
83+
def test_timeout(options, timeout):
84+
client = _http_client.HttpClient(**options)
85+
assert client.timeout == timeout
86+
recorder = _instrument(client, 'body')
87+
client.request('get', _TEST_URL)
88+
assert len(recorder) == 1
89+
if timeout is None:
90+
assert recorder[0]._extra_kwargs['timeout'] is None
91+
else:
92+
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001)
93+
94+
7795
def _instrument(client, payload, status=200):
7896
recorder = []
7997
adapter = testutils.MockAdapter(payload, status, recorder)

tests/test_instance_id.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import firebase_admin
2020
from firebase_admin import exceptions
2121
from firebase_admin import instance_id
22+
from firebase_admin import _http_client
2223
from tests import testutils
2324

2425

@@ -73,6 +74,12 @@ def evaluate():
7374
instance_id.delete_instance_id('test')
7475
testutils.run_without_project_id(evaluate)
7576

77+
def test_default_timeout(self):
78+
cred = testutils.MockCredential()
79+
app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'})
80+
iid_service = instance_id._get_iid_service(app)
81+
assert iid_service._client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
82+
7683
def test_delete_instance_id(self):
7784
cred = testutils.MockCredential()
7885
app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'})

0 commit comments

Comments
 (0)