Skip to content

fix: Setting a default timeout on all HTTP connections #397

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jan 30, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions firebase_admin/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -900,13 +900,13 @@ def __init__(self, credential, base_url, timeout, params=None):
credential: A Google credential that can be used to authenticate requests.
base_url: A URL prefix to be added to all outgoing requests. This is typically the
Firebase Realtime Database URL.
timeout: HTTP request timeout in seconds. If not set connections will never
timeout: HTTP request timeout in seconds. If set to None connections will never
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the 'which is the default behaviour ...' part is no longer true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If set to None connections will never
timeout, which is the default behavior of the underlying requests library.

That is still true. The requests library behavior hasn't yet changed. The default timeout=None behavior still comes from there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops; yeah I missed the change from 'if unset' to 'if set to none' (despite suggesting exactly that earlier. 😳)

timeout, which is the default behavior of the underlying requests library.
params: Dict of query parameters to add to all outgoing requests.
"""
_http_client.JsonHttpClient.__init__(
self, credential=credential, base_url=base_url, timeout=timeout,
headers={'User-Agent': _USER_AGENT})
super().__init__(
credential=credential, base_url=base_url,
timeout=timeout, headers={'User-Agent': _USER_AGENT})
self.params = params if params else {}

def request(self, method, url, **kwargs):
Expand Down
49 changes: 23 additions & 26 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,16 +732,8 @@ def test_parse_db_url_errors(self, url, emulator_host):
def test_valid_db_url(self, url):
firebase_admin.initialize_app(testutils.MockCredential(), {'databaseURL' : url})
ref = db.reference()
recorder = []
adapter = MockAdapter('{}', 200, recorder)
ref._client.session.mount(url, adapter)
assert ref._client.base_url == 'https://test.firebaseio.com'
assert 'auth_variable_override' not in ref._client.params
assert ref._client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
assert ref.get() == {}
assert len(recorder) == 1
assert recorder[0]._extra_kwargs.get('timeout') == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)

@pytest.mark.parametrize('url', [
None, '', 'foo', 'http://test.firebaseio.com', 'https://google.com',
Expand All @@ -763,15 +755,13 @@ def test_multi_db_support(self):
ref = db.reference()
assert ref._client.base_url == default_url
assert 'auth_variable_override' not in ref._client.params
assert ref._client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
assert ref._client is db.reference()._client
assert ref._client is db.reference(url=default_url)._client

other_url = 'https://other.firebaseio.com'
other_ref = db.reference(url=other_url)
assert other_ref._client.base_url == other_url
assert 'auth_variable_override' not in ref._client.params
assert other_ref._client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
assert other_ref._client is db.reference(url=other_url)._client
assert other_ref._client is db.reference(url=other_url + '/')._client

Expand All @@ -784,7 +774,6 @@ def test_valid_auth_override(self, override):
default_ref = db.reference()
other_ref = db.reference(url='https://other.firebaseio.com')
for ref in [default_ref, other_ref]:
assert ref._client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
if override == {}:
assert 'auth_variable_override' not in ref._client.params
else:
Expand All @@ -806,26 +795,22 @@ def test_invalid_auth_override(self, override):
with pytest.raises(ValueError):
db.reference(app=other_app, url='https://other.firebaseio.com')

@pytest.mark.parametrize('timeout', [60, None])
def test_http_timeout(self, timeout):
@pytest.mark.parametrize('options, timeout', [
({'httpTimeout': 4}, 4),
({'httpTimeout': None}, None),
({}, _http_client.DEFAULT_TIMEOUT_SECONDS),
])
def test_http_timeout(self, options, timeout):
test_url = 'https://test.firebaseio.com'
firebase_admin.initialize_app(testutils.MockCredential(), {
all_options = {
'databaseURL' : test_url,
'httpTimeout': timeout
})
}
all_options.update(options)
firebase_admin.initialize_app(testutils.MockCredential(), all_options)
default_ref = db.reference()
other_ref = db.reference(url='https://other.firebaseio.com')
for ref in [default_ref, other_ref]:
recorder = []
adapter = MockAdapter('{}', 200, recorder)
ref._client.session.mount(ref._client.base_url, adapter)
assert ref._client.timeout == timeout
assert ref.get() == {}
assert len(recorder) == 1
if timeout is None:
assert recorder[0]._extra_kwargs['timeout'] is None
else:
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001)
self._check_timeout(ref, timeout)

def test_app_delete(self):
app = firebase_admin.initialize_app(
Expand All @@ -847,6 +832,18 @@ def test_user_agent_format(self):
firebase_admin.__version__, sys.version_info.major, sys.version_info.minor)
assert db._USER_AGENT == expected

def _check_timeout(self, ref, timeout):
assert ref._client.timeout == timeout
recorder = []
adapter = MockAdapter('{}', 200, recorder)
ref._client.session.mount(ref._client.base_url, adapter)
assert ref.get() == {}
assert len(recorder) == 1
if timeout is None:
assert recorder[0]._extra_kwargs['timeout'] is None
else:
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001)


@pytest.fixture(params=['foo', '$key', '$value'])
def initquery(request):
Expand Down
35 changes: 13 additions & 22 deletions tests/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,53 +28,43 @@ def test_http_client_default_session():
client = _http_client.HttpClient()
assert client.session is not None
assert client.base_url == ''
assert client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
recorder = _instrument(client, 'body')
resp = client.request('get', _TEST_URL)
assert resp.status_code == 200
assert resp.text == 'body'
assert len(recorder) == 1
assert recorder[0].method == 'GET'
assert recorder[0].url == _TEST_URL
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)

def test_http_client_custom_session():
session = requests.Session()
client = _http_client.HttpClient(session=session)
assert client.session is session
assert client.base_url == ''
assert client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
recorder = _instrument(client, 'body')
resp = client.request('get', _TEST_URL)
assert resp.status_code == 200
assert resp.text == 'body'
assert len(recorder) == 1
assert recorder[0].method == 'GET'
assert recorder[0].url == _TEST_URL
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)

def test_base_url():
client = _http_client.HttpClient(base_url=_TEST_URL)
assert client.session is not None
assert client.base_url == _TEST_URL
assert client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
recorder = _instrument(client, 'body')
resp = client.request('get', 'foo')
assert resp.status_code == 200
assert resp.text == 'body'
assert len(recorder) == 1
assert recorder[0].method == 'GET'
assert recorder[0].url == _TEST_URL + 'foo'
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)

def test_credential():
client = _http_client.HttpClient(
credential=testutils.MockGoogleCredential())
assert client.session is not None
assert client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
recorder = _instrument(client, 'body')
resp = client.request('get', _TEST_URL)
assert resp.status_code == 200
Expand All @@ -83,26 +73,27 @@ def test_credential():
assert recorder[0].method == 'GET'
assert recorder[0].url == _TEST_URL
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'

def test_default_timeout():
client = _http_client.HttpClient()
assert client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS
recorder = _instrument(client, 'body')
client.request('get', _TEST_URL)
assert len(recorder) == 1
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)

@pytest.mark.parametrize('timeout', [7, None])
def test_timeout(timeout):
@pytest.mark.parametrize('timeout', [7, 0, None])
def test_custom_timeout(timeout):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function looks mostly identical to test_default_timeout. You could optionally refactor to something like this:

def _helper_test_timeout(**kwargs):
    client = _http_client.HttpClient()
    timeout = kwargs.get('timeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
    assert client.timeout == timeout
    ...
    if timeout is None
        assert recorder[0]._extra_kwargs['timeout'] is None
    else:
        assert ... == pytest.approx(timeout, 0.001)

def test_default_timeout():
    _helper_test_timeout()

@parameterize(...)
def test_custom_timeout(timeout)
    _helper_test_timeout(timeout=timeout)

(Or you could parameterize the helper directly.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored with parameterization.

client = _http_client.HttpClient(timeout=timeout)
assert client.session is not None
assert client.base_url == ''
assert client.timeout == timeout
recorder = _instrument(client, 'body')
resp = client.request('get', _TEST_URL)
assert resp.status_code == 200
assert resp.text == 'body'
client.request('get', _TEST_URL)
assert len(recorder) == 1
assert recorder[0].method == 'GET'
assert recorder[0].url == _TEST_URL
if timeout:
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001)
else:
if timeout is None:
assert recorder[0]._extra_kwargs['timeout'] is None
else:
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001)


def _instrument(client, payload, status=200):
Expand Down
73 changes: 41 additions & 32 deletions tests/test_messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -1538,42 +1538,57 @@ def test_aps_alert_custom_data_override(self):
}
check_encoding(msg, expected)

class TestTimeout:

@classmethod
def setup_class(cls):
cred = testutils.MockCredential()
firebase_admin.initialize_app(cred, {'httpTimeout': 4, 'projectId': 'explicit-project-id'})
class TestTimeout:

@classmethod
def teardown_class(cls):
def teardown(self):
testutils.cleanup_apps()

def setup(self):
def _instrument_service(self, url, response):
app = firebase_admin.get_app()
self.fcm_service = messaging._get_messaging_service(app)
self.recorder = []
fcm_service = messaging._get_messaging_service(app)
recorder = []
fcm_service._client.session.mount(
url, testutils.MockAdapter(json.dumps(response), 200, recorder))
return recorder

def test_send(self):
self.fcm_service._client.session.mount(
'https://fcm.googleapis.com',
testutils.MockAdapter(json.dumps({'name': 'message-id'}), 200, self.recorder))
def _check_timeout(self, recorder, timeout):
assert len(recorder) == 1
if timeout is None:
assert recorder[0]._extra_kwargs['timeout'] is None
else:
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(timeout, 0.001)

@pytest.mark.parametrize('options, timeout', [
({'httpTimeout': 4}, 4),
({'httpTimeout': None}, None),
({}, _http_client.DEFAULT_TIMEOUT_SECONDS),
])
def test_send(self, options, timeout):
cred = testutils.MockCredential()
all_options = {'projectId': 'explicit-project-id'}
all_options.update(options)
firebase_admin.initialize_app(cred, all_options)
recorder = self._instrument_service(
'https://fcm.googleapis.com', {'name': 'message-id'})
msg = messaging.Message(topic='foo')
messaging.send(msg)
assert len(self.recorder) == 1
assert self.recorder[0]._extra_kwargs['timeout'] == pytest.approx(4, 0.001)
self._check_timeout(recorder, timeout)

def test_topic_management_timeout(self):
self.fcm_service._client.session.mount(
'https://iid.googleapis.com',
testutils.MockAdapter(
json.dumps({'results': [{}, {'error': 'error_reason'}]}),
200,
self.recorder)
)
@pytest.mark.parametrize('options, timeout', [
({'httpTimeout': 4}, 4),
({'httpTimeout': None}, None),
({}, _http_client.DEFAULT_TIMEOUT_SECONDS),
])
def test_topic_management_custom_timeout(self, options, timeout):
cred = testutils.MockCredential()
all_options = {'projectId': 'explicit-project-id'}
all_options.update(options)
firebase_admin.initialize_app(cred, all_options)
recorder = self._instrument_service(
'https://iid.googleapis.com', {'results': [{}, {'error': 'error_reason'}]})
messaging.subscribe_to_topic(['1'], 'a')
assert len(self.recorder) == 1
assert self.recorder[0]._extra_kwargs['timeout'] == pytest.approx(4, 0.001)
self._check_timeout(recorder, timeout)


class TestSend:
Expand Down Expand Up @@ -1642,8 +1657,6 @@ def test_send(self):
assert recorder[0].url == self._get_url('explicit-project-id')
assert recorder[0].headers['X-GOOG-API-FORMAT-VERSION'] == '2'
assert recorder[0].headers['X-FIREBASE-CLIENT'] == self._CLIENT_VERSION
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)
body = {'message': messaging._MessagingService.encode_message(msg)}
assert json.loads(recorder[0].body.decode()) == body

Expand Down Expand Up @@ -2226,8 +2239,6 @@ def test_subscribe_to_topic(self, args):
assert recorder[0].method == 'POST'
assert recorder[0].url == self._get_url('iid/v1:batchAdd')
assert json.loads(recorder[0].body.decode()) == args[2]
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)

@pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items())
def test_subscribe_to_topic_error(self, status, exc_type):
Expand Down Expand Up @@ -2260,8 +2271,6 @@ def test_unsubscribe_from_topic(self, args):
assert recorder[0].method == 'POST'
assert recorder[0].url == self._get_url('iid/v1:batchRemove')
assert json.loads(recorder[0].body.decode()) == args[2]
assert recorder[0]._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)

@pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items())
def test_unsubscribe_from_topic_error(self, status, exc_type):
Expand Down
23 changes: 21 additions & 2 deletions tests/test_project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,14 +523,33 @@ def _assert_request_is_correct(
assert request.url == expected_url
client_version = 'Python/Admin/{0}'.format(firebase_admin.__version__)
assert request.headers['X-Client-Version'] == client_version
assert request._extra_kwargs['timeout'] == pytest.approx(
_http_client.DEFAULT_TIMEOUT_SECONDS, 0.001)
if expected_body is None:
assert request.body is None
else:
assert json.loads(request.body.decode()) == expected_body


class TestTimeout(BaseProjectManagementTest):

def test_default_timeout(self):
app = firebase_admin.get_app()
project_management_service = project_management._get_project_management_service(app)
assert project_management_service._client.timeout == _http_client.DEFAULT_TIMEOUT_SECONDS

@pytest.mark.parametrize('timeout', [4, None])
def test_custom_timeout(self, timeout):
options = {
'httpTimeout': timeout,
'projectId': 'test-project-id'
}
app = firebase_admin.initialize_app(testutils.MockCredential(), options, 'timeout-app')
try:
project_management_service = project_management._get_project_management_service(app)
assert project_management_service._client.timeout == timeout
finally:
firebase_admin.delete_app(app)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseProjectManagementTest seems to call testutils.cleanup_apps() in it's teardown() method. If you're willing to rely on that, you could drop the try, finally, and delete_app() lines. Presumably you'd nave to not hard-code the app name ("timeout-app") but instead use something based on the test parameter or something like that instead. Optional.

I have no objection to leaving it in either, but it's odd to see the app cleaned up in this test case, but not the one above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done



class TestCreateAndroidApp(BaseProjectManagementTest):
_CREATION_URL = 'https://firebase.googleapis.com/v1beta1/projects/test-project-id/androidApps'

Expand Down