diff --git a/poetry/installation/authenticator.py b/poetry/installation/authenticator.py index 795a72719f6..1ba496a4a3a 100644 --- a/poetry/installation/authenticator.py +++ b/poetry/installation/authenticator.py @@ -1,5 +1,12 @@ +import time + from typing import TYPE_CHECKING +import requests +import requests.auth +import requests.exceptions + +from poetry.exceptions import PoetryException from poetry.utils._compat import urlparse from poetry.utils.password_manager import PasswordManager @@ -10,10 +17,6 @@ from typing import Tuple from clikit.api.io import IO - from requests import Request # noqa - from requests import Response # noqa - from requests import Session # noqa - from poetry.config.config import Config @@ -26,24 +29,22 @@ def __init__(self, config, io): # type: (Config, IO) -> None self._password_manager = PasswordManager(self._config) @property - def session(self): # type: () -> Session - from requests import Session # noqa - + def session(self): # type: () -> requests.Session if self._session is None: - self._session = Session() + self._session = requests.Session() return self._session - def request(self, method, url, **kwargs): # type: (str, str, Any) -> Response - from requests import Request # noqa - from requests.auth import HTTPBasicAuth - - request = Request(method, url) + def request( + self, method, url, **kwargs + ): # type: (str, str, Any) -> requests.Response + request = requests.Request(method, url) + io = kwargs.get("io", self._io) username, password = self._get_credentials_for_url(url) if username is not None and password is not None: - request = HTTPBasicAuth(username, password)(request) + request = requests.auth.HTTPBasicAuth(username, password)(request) session = self.session prepared_request = session.prepare_request(request) @@ -63,11 +64,32 @@ def request(self, method, url, **kwargs): # type: (str, str, Any) -> Response "allow_redirects": kwargs.get("allow_redirects", True), } send_kwargs.update(settings) - resp = session.send(prepared_request, **send_kwargs) - resp.raise_for_status() + attempt = 0 + + while True: + is_last_attempt = attempt >= 5 + try: + resp = session.send(prepared_request, **send_kwargs) + except (requests.exceptions.ConnectionError, OSError) as e: + if is_last_attempt: + raise e + else: + if resp.status_code not in [502, 503, 504] or is_last_attempt: + resp.raise_for_status() + return resp + + if not is_last_attempt: + attempt += 1 + delay = 0.5 * attempt + io.write_line( + "Retrying HTTP request in {} seconds.".format(delay) + ) + time.sleep(delay) + continue - return resp + # this should never really be hit under any sane circumstance + raise PoetryException("Failed HTTP {} request", method.upper()) def _get_credentials_for_url( self, url diff --git a/poetry/installation/executor.py b/poetry/installation/executor.py index a8bf3555d7a..98b9bbc7056 100644 --- a/poetry/installation/executor.py +++ b/poetry/installation/executor.py @@ -603,7 +603,9 @@ def _download_link(self, operation, link): return archive def _download_archive(self, operation, link): # type: (Operation, Link) -> Path - response = self._authenticator.request("get", link.url, stream=True) + response = self._authenticator.request( + "get", link.url, stream=True, io=self._sections.get(id(operation)) + ) wheel_size = response.headers.get("content-length") operation_message = self.get_operation_message(operation) message = " • {message}: Downloading...".format( diff --git a/tests/installation/test_authenticator.py b/tests/installation/test_authenticator.py index bd09ad86663..4cf323ef708 100644 --- a/tests/installation/test_authenticator.py +++ b/tests/installation/test_authenticator.py @@ -1,6 +1,9 @@ import re +import uuid +import httpretty import pytest +import requests from poetry.installation.authenticator import Authenticator from poetry.io.null_io import NullIO @@ -113,3 +116,67 @@ def test_authenticator_uses_empty_strings_as_default_username( request = http.last_request() assert "Basic OmJhcg==" == request.headers["Authorization"] + + +def test_authenticator_request_retries_on_exception(mocker, config, http): + sleep = mocker.patch("time.sleep") + sdist_uri = "https://foo.bar/files/{}/foo-0.1.0.tar.gz".format(str(uuid.uuid4())) + content = str(uuid.uuid4()) + seen = list() + + def callback(request, uri, response_headers): + if seen.count(uri) < 2: + seen.append(uri) + raise requests.exceptions.ConnectionError("Disconnected") + return [200, response_headers, content] + + httpretty.register_uri(httpretty.GET, sdist_uri, body=callback) + + authenticator = Authenticator(config, NullIO()) + response = authenticator.request("get", sdist_uri) + assert response.text == content + assert sleep.call_count == 2 + + +def test_authenticator_request_raises_exception_when_attempts_exhausted( + mocker, config, http +): + sleep = mocker.patch("time.sleep") + sdist_uri = "https://foo.bar/files/{}/foo-0.1.0.tar.gz".format(str(uuid.uuid4())) + + def callback(*_, **__): + raise requests.exceptions.ConnectionError(str(uuid.uuid4())) + + httpretty.register_uri(httpretty.GET, sdist_uri, body=callback) + authenticator = Authenticator(config, NullIO()) + + with pytest.raises(requests.exceptions.ConnectionError): + authenticator.request("get", sdist_uri) + + assert sleep.call_count == 5 + + +@pytest.mark.parametrize( + "status, attempts", + [(400, 0), (401, 0), (403, 0), (404, 0), (500, 0), (502, 5), (503, 5), (504, 5)], +) +def test_authenticator_request_retries_on_status_code( + mocker, config, http, status, attempts +): + sleep = mocker.patch("time.sleep") + sdist_uri = "https://foo.bar/files/{}/foo-0.1.0.tar.gz".format(str(uuid.uuid4())) + content = str(uuid.uuid4()) + + def callback(request, uri, response_headers): + return [status, response_headers, content] + + httpretty.register_uri(httpretty.GET, sdist_uri, body=callback) + authenticator = Authenticator(config, NullIO()) + + with pytest.raises(requests.exceptions.HTTPError) as excinfo: + authenticator.request("get", sdist_uri) + + assert excinfo.value.response.status_code == status + assert excinfo.value.response.text == content + + assert sleep.call_count == attempts