diff --git a/dev-requirements.txt b/dev-requirements.txt index dac6785e07..3e31cc0e3e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -15,3 +15,5 @@ gcp-devrel-py-tools==0.0.15 # https://github.com/GoogleCloudPlatform/python-repo-tools/issues/23 pylint<2.0;python_version<="2.7" + +python-dateutil==2.8.1 diff --git a/src/urllib3/util/retry.py b/src/urllib3/util/retry.py index d5df6af3ea..e5eda7a16d 100644 --- a/src/urllib3/util/retry.py +++ b/src/urllib3/util/retry.py @@ -269,6 +269,13 @@ def parse_retry_after(self, retry_after): retry_date_tuple = email.utils.parsedate_tz(retry_after) if retry_date_tuple is None: raise InvalidHeader("Invalid Retry-After header: %s" % retry_after) + if retry_date_tuple[9] is None: # Python 2 + # Assume UTC if no timezone was specified + # On Python2.7, parsedate_tz returns None for a timezone offset + # instead of 0 if no timezone is given, where mktime_tz treats + # a None timezone offset as local time. + retry_date_tuple = retry_date_tuple[:9] + (0,) + retry_date_tuple[10:] + retry_date = email.utils.mktime_tz(retry_date_tuple) seconds = retry_date - time.time() diff --git a/test/conftest.py b/test/conftest.py index f4bf8370ac..84e6c18e33 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -8,6 +8,8 @@ import trustme from tornado import web, ioloop +from .tz_stub import stub_timezone_ctx + from dummyserver.handlers import TestingApp from dummyserver.server import run_tornado_app from dummyserver.server import HAS_IPV6 @@ -96,3 +98,12 @@ def ipv6_san_server(tmp_path_factory): with run_server_in_thread("https", "::1", tmpdir, ca, server_cert) as cfg: yield cfg + + +@pytest.yield_fixture +def stub_timezone(request): + """ + A pytest fixture that runs the test with a stub timezone. + """ + with stub_timezone_ctx(request.param): + yield diff --git a/test/test_retry.py b/test/test_retry.py index 0aee731fc5..a29b03e2cd 100644 --- a/test/test_retry.py +++ b/test/test_retry.py @@ -332,6 +332,16 @@ def test_respect_retry_after_header_propagated(self, respect_retry_after_header) ("Mon Jun 3 11:30:12 2019", True, 1812), ], ) + @pytest.mark.parametrize( + "stub_timezone", + [ + "UTC", + "Asia/Jerusalem", + None, + ], + indirect=True, + ) + @pytest.mark.usefixtures("stub_timezone") def test_respect_retry_after_header_sleep( self, retry_after_header, respect_retry_after_header, sleep_duration ): diff --git a/test/tz_stub.py b/test/tz_stub.py new file mode 100644 index 0000000000..5b1a8c7e18 --- /dev/null +++ b/test/tz_stub.py @@ -0,0 +1,39 @@ +from contextlib import contextmanager +import time +import datetime +import os +import pytest +from dateutil import tz + + +@contextmanager +def stub_timezone_ctx(tzname): + """ + Switch to a locally-known timezone specified by `tzname`. + On exit, restore the previous timezone. + If `tzname` is `None`, do nothing. + """ + if tzname is None: + yield + return + + # Only supported on Unix + if not hasattr(time, "tzset"): + pytest.skip("Timezone patching is not supported") + + # Make sure the new timezone exists, at least in dateutil + new_tz = tz.gettz(tzname) + if new_tz is None: + raise ValueError("Invalid timezone specified: %r" % (tzname,)) + + # Get the current timezone + local_tz = tz.tzlocal() + if local_tz is None: + raise EnvironmentError("Cannot determine current timezone") + old_tzname = datetime.datetime.now(local_tz).tzname() + + os.environ["TZ"] = tzname + time.tzset() + yield + os.environ["TZ"] = old_tzname + time.tzset()