Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Unreleased

-
- [added] Implemented HTTP retries. The SDK now retries HTTP calls on
low-level connection and socket read errors, as well as HTTP 500 and
503 errors.

# v2.15.0

Expand Down
24 changes: 21 additions & 3 deletions firebase_admin/_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,28 @@

from google.auth import transport
import requests
from requests.packages.urllib3.util import retry # pylint: disable=import-error


# Default retry configuration: Retries once on low-level connection and socket read errors.
# Retries up to 4 times on HTTP 500 and 503 errors, with exponential backoff. Returns the
# last response upon exhausting all retries.
DEFAULT_RETRY_CONFIG = retry.Retry(
connect=1, read=1, status=4, status_forcelist=[500, 503],
raise_on_status=False, backoff_factor=0.5)
Copy link

Choose a reason for hiding this comment

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

I am not finding raise_on_status as an argument to Retry object defined in version 2.21.0 of requests module (most recent public version). Perhaps you are getting some other version? This is causing an error during import of firebase_admin.admin on my setup.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

retry.Retry is a class defined in urllib3. It has supported raise_on_status since v1.15 (released in 2016): urllib3/urllib3@9f48d26

You need to upgrade urllib3 to something more recent.

Copy link

Choose a reason for hiding this comment

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

Your code reaches into a copy of urllib3 that's bundled with requests. It's not possible to upgrade that independently, AFAIK. I am using the newest requests (2.21.0) and it contains this issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This has been developed and tested on latest requests. I double checked just now to make sure:

>>> import requests
>>> requests.__version__
'2.21.0'
>>> import firebase_admin
>>> firebase_admin.__version__
'2.15.1'
>>> from firebase_admin import db
>>>
>>> from requests.packages.urllib3.util import retry
>>> print(retry.Retry.__doc__)
 Retry configuration.

    Each retry attempt will create a new Retry object with updated values, so
    they can be safely reused.
...
    :param bool raise_on_status: Similar meaning to ``raise_on_redirect``:
        whether we should raise an exception, or return a response,
        if status falls in ``status_forcelist`` range and retries have
        been exhausted.
...

requests does not ship urllib3 with it (as far as I can tell). It just re-exports whatever it can find already installed in the system: https://github.com/requests/requests/blob/v2.21.0/requests/packages.py

Copy link

Choose a reason for hiding this comment

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

requests has urllib3>=1.21.1,<1.25 as a requirement (in setup.py), and that range of versions will do what we want. However, my build process is using pip to construct zipfile modules (because appengine limitations) and when pip is finished with requests, the packages subdirectory inside has an obsolete version of urllib3 captured into the zip. I've since learned that is coming from the virtualenv being used for building, even though I've added urllib3 to my requirements.txt, and pip finds and downloads urllib3 1.24, but that is not what gets captured into the zipfile holding requests!

If your code used from urllib3.util import retry and specified urllib3 as a dependancy, then all would be well. Since we might hope that requests will cleanup and remove the packages hack someday, maybe that would be a net improvement regardless?

Thanks for your help today.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That sounds like a reasonable change to make. Although I'm a little concerned about declaring a dependency on urllib3. We had to stop declaring requests as a dependency to work around a bug related to Firestore and pip. I wonder if adding urllib3 to our setup file will cause issues. But I can certainly experiment with that option and see if it's possible.



class HttpClient(object):
"""Base HTTP client used to make HTTP calls.

HttpClient maintains an HTTP session, and handles request authentication if necessary.
HttpClient maintains an HTTP session, and handles request authentication and retries if
necessary.
"""

def __init__(self, credential=None, session=None, base_url='', headers=None):
"""Cretes a new HttpClient instance from the provided arguments.
def __init__(
self, credential=None, session=None, base_url='', headers=None,
retries=DEFAULT_RETRY_CONFIG):
"""Creates a new HttpClient instance from the provided arguments.

If a credential is provided, initializes a new HTTP session authorized with it. If neither
a credential nor a session is provided, initializes a new unauthorized session.
Expand All @@ -38,6 +50,9 @@ def __init__(self, credential=None, session=None, base_url='', headers=None):
session: A custom HTTP session (optional).
base_url: A URL prefix to be added to all outgoing requests (optional).
headers: A map of headers to be added to all outgoing requests (optional).
retries: A urllib retry configuration. Default settings would retry once for low-level
connection and socket read errors, and up to 4 times for HTTP 500 and 503 errors.
Pass a False value to disable retries (optional).
"""
if credential:
self._session = transport.requests.AuthorizedSession(credential)
Expand All @@ -48,6 +63,9 @@ def __init__(self, credential=None, session=None, base_url='', headers=None):

if headers:
self._session.headers.update(headers)
if retries:
self._session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries))
self._session.mount('https://', requests.adapters.HTTPAdapter(max_retries=retries))
self._base_url = base_url

@property
Expand Down
54 changes: 54 additions & 0 deletions tests/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@
# limitations under the License.

"""Tests for firebase_admin._http_client."""
import pytest
from pytest_localserver import plugin
import requests

from firebase_admin import _http_client
from tests import testutils


# Fixture for mocking a HTTP server
httpserver = plugin.httpserver

_TEST_URL = 'http://firebase.test.url/'


Expand Down Expand Up @@ -59,8 +64,57 @@ def test_base_url():
assert recorder[0].method == 'GET'
assert recorder[0].url == _TEST_URL + 'foo'

def test_credential():
client = _http_client.HttpClient(
credential=testutils.MockGoogleCredential())
assert client.session is not None
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].headers['Authorization'] == 'Bearer mock-token'

def _instrument(client, payload, status=200):
recorder = []
adapter = testutils.MockAdapter(payload, status, recorder)
client.session.mount(_TEST_URL, adapter)
return recorder


class TestHttpRetry(object):
"""Unit tests for the default HTTP retry configuration."""

@classmethod
def setup_class(cls):
# Turn off exponential backoff for faster execution
_http_client.DEFAULT_RETRY_CONFIG.backoff_factor = 0

def test_retry_on_503(self, httpserver):
httpserver.serve_content({}, 503)
client = _http_client.JsonHttpClient(
credential=testutils.MockGoogleCredential(), base_url=httpserver.url)
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
client.request('get', '/')
assert excinfo.value.response.status_code == 503
assert len(httpserver.requests) == 5

def test_retry_on_500(self, httpserver):
httpserver.serve_content({}, 500)
client = _http_client.JsonHttpClient(
credential=testutils.MockGoogleCredential(), base_url=httpserver.url)
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
client.request('get', '/')
assert excinfo.value.response.status_code == 500
assert len(httpserver.requests) == 5

def test_no_retry_on_404(self, httpserver):
httpserver.serve_content({}, 404)
client = _http_client.JsonHttpClient(
credential=testutils.MockGoogleCredential(), base_url=httpserver.url)
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
client.request('get', '/')
assert excinfo.value.response.status_code == 404
assert len(httpserver.requests) == 1