Skip to content

Commit 05acb8a

Browse files
authored
Implementing HTTP retries for the SDK (#247)
* Implementing HTTP retries for the SDK * Adding test case for credential * Updated changelog
1 parent b199dfa commit 05acb8a

File tree

3 files changed

+78
-4
lines changed

3 files changed

+78
-4
lines changed

CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Unreleased
22

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

57
# v2.15.0
68

firebase_admin/_http_client.py

+21-3
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,28 @@
1919

2020
from google.auth import transport
2121
import requests
22+
from requests.packages.urllib3.util import retry # pylint: disable=import-error
23+
24+
25+
# Default retry configuration: Retries once on low-level connection and socket read errors.
26+
# Retries up to 4 times on HTTP 500 and 503 errors, with exponential backoff. Returns the
27+
# last response upon exhausting all retries.
28+
DEFAULT_RETRY_CONFIG = retry.Retry(
29+
connect=1, read=1, status=4, status_forcelist=[500, 503],
30+
raise_on_status=False, backoff_factor=0.5)
2231

2332

2433
class HttpClient(object):
2534
"""Base HTTP client used to make HTTP calls.
2635
27-
HttpClient maintains an HTTP session, and handles request authentication if necessary.
36+
HttpClient maintains an HTTP session, and handles request authentication and retries if
37+
necessary.
2838
"""
2939

30-
def __init__(self, credential=None, session=None, base_url='', headers=None):
31-
"""Cretes a new HttpClient instance from the provided arguments.
40+
def __init__(
41+
self, credential=None, session=None, base_url='', headers=None,
42+
retries=DEFAULT_RETRY_CONFIG):
43+
"""Creates a new HttpClient instance from the provided arguments.
3244
3345
If a credential is provided, initializes a new HTTP session authorized with it. If neither
3446
a credential nor a session is provided, initializes a new unauthorized session.
@@ -38,6 +50,9 @@ def __init__(self, credential=None, session=None, base_url='', headers=None):
3850
session: A custom HTTP session (optional).
3951
base_url: A URL prefix to be added to all outgoing requests (optional).
4052
headers: A map of headers to be added to all outgoing requests (optional).
53+
retries: A urllib retry configuration. Default settings would retry once for low-level
54+
connection and socket read errors, and up to 4 times for HTTP 500 and 503 errors.
55+
Pass a False value to disable retries (optional).
4156
"""
4257
if credential:
4358
self._session = transport.requests.AuthorizedSession(credential)
@@ -48,6 +63,9 @@ def __init__(self, credential=None, session=None, base_url='', headers=None):
4863

4964
if headers:
5065
self._session.headers.update(headers)
66+
if retries:
67+
self._session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries))
68+
self._session.mount('https://', requests.adapters.HTTPAdapter(max_retries=retries))
5169
self._base_url = base_url
5270

5371
@property

tests/test_http_client.py

+54
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@
1313
# limitations under the License.
1414

1515
"""Tests for firebase_admin._http_client."""
16+
import pytest
17+
from pytest_localserver import plugin
1618
import requests
1719

1820
from firebase_admin import _http_client
1921
from tests import testutils
2022

2123

24+
# Fixture for mocking a HTTP server
25+
httpserver = plugin.httpserver
26+
2227
_TEST_URL = 'http://firebase.test.url/'
2328

2429

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

67+
def test_credential():
68+
client = _http_client.HttpClient(
69+
credential=testutils.MockGoogleCredential())
70+
assert client.session is not None
71+
recorder = _instrument(client, 'body')
72+
resp = client.request('get', _TEST_URL)
73+
assert resp.status_code == 200
74+
assert resp.text == 'body'
75+
assert len(recorder) == 1
76+
assert recorder[0].method == 'GET'
77+
assert recorder[0].url == _TEST_URL
78+
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'
79+
6280
def _instrument(client, payload, status=200):
6381
recorder = []
6482
adapter = testutils.MockAdapter(payload, status, recorder)
6583
client.session.mount(_TEST_URL, adapter)
6684
return recorder
85+
86+
87+
class TestHttpRetry(object):
88+
"""Unit tests for the default HTTP retry configuration."""
89+
90+
@classmethod
91+
def setup_class(cls):
92+
# Turn off exponential backoff for faster execution
93+
_http_client.DEFAULT_RETRY_CONFIG.backoff_factor = 0
94+
95+
def test_retry_on_503(self, httpserver):
96+
httpserver.serve_content({}, 503)
97+
client = _http_client.JsonHttpClient(
98+
credential=testutils.MockGoogleCredential(), base_url=httpserver.url)
99+
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
100+
client.request('get', '/')
101+
assert excinfo.value.response.status_code == 503
102+
assert len(httpserver.requests) == 5
103+
104+
def test_retry_on_500(self, httpserver):
105+
httpserver.serve_content({}, 500)
106+
client = _http_client.JsonHttpClient(
107+
credential=testutils.MockGoogleCredential(), base_url=httpserver.url)
108+
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
109+
client.request('get', '/')
110+
assert excinfo.value.response.status_code == 500
111+
assert len(httpserver.requests) == 5
112+
113+
def test_no_retry_on_404(self, httpserver):
114+
httpserver.serve_content({}, 404)
115+
client = _http_client.JsonHttpClient(
116+
credential=testutils.MockGoogleCredential(), base_url=httpserver.url)
117+
with pytest.raises(requests.exceptions.HTTPError) as excinfo:
118+
client.request('get', '/')
119+
assert excinfo.value.response.status_code == 404
120+
assert len(httpserver.requests) == 1

0 commit comments

Comments
 (0)