Skip to content

Commit ba16609

Browse files
authored
CM-46371 - Add retry behavior for HTTP requests (#291)
1 parent f4ae0fa commit ba16609

File tree

6 files changed

+88
-16
lines changed

6 files changed

+88
-16
lines changed

cycode/cli/apps/auth/auth_manager.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
import webbrowser
33
from typing import TYPE_CHECKING, Tuple
44

5-
from requests import Request
6-
75
from cycode.cli.exceptions.custom_exceptions import AuthProcessError
86
from cycode.cli.user_settings.configuration_manager import ConfigurationManager
97
from cycode.cli.user_settings.credentials_manager import CredentialsManager
@@ -53,7 +51,7 @@ def start_session(self, code_challenge: str) -> str:
5351
return auth_session.session_id
5452

5553
def redirect_to_login_page(self, code_challenge: str, session_id: str) -> None:
56-
login_url = self._build_login_url(code_challenge, session_id)
54+
login_url = self.auth_client.build_login_url(code_challenge, session_id)
5755
webbrowser.open(login_url)
5856

5957
def get_api_token(self, session_id: str, code_verifier: str) -> 'ApiToken':
@@ -75,19 +73,11 @@ def get_api_token_polling(self, session_id: str, code_verifier: str) -> 'ApiToke
7573
raise AuthProcessError('Error while obtaining API token')
7674
time.sleep(self.POLLING_WAIT_INTERVAL_IN_SECONDS)
7775

78-
raise AuthProcessError('session expired')
76+
raise AuthProcessError('Timeout while obtaining API token (session expired)')
7977

8078
def save_api_token(self, api_token: 'ApiToken') -> None:
8179
self.credentials_manager.update_credentials(api_token.client_id, api_token.secret)
8280

83-
def _build_login_url(self, code_challenge: str, session_id: str) -> str:
84-
app_url = self.configuration_manager.get_cycode_app_url()
85-
login_url = f'{app_url}/account/sign-in'
86-
query_params = {'source': 'cycode_cli', 'code_challenge': code_challenge, 'session_id': session_id}
87-
# TODO(MarshalX). Use auth_client instead and don't depend on "requests" lib here
88-
request = Request(url=login_url, params=query_params)
89-
return request.prepare().url
90-
9181
def _generate_pkce_code_pair(self) -> Tuple[str, str]:
9282
code_verifier = generate_random_string(self.CODE_VERIFIER_LENGTH)
9383
code_challenge = hash_string_to_sha256(code_verifier)

cycode/cyclient/auth_client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from typing import Optional
22

3-
from requests import Response
3+
from requests import Request, Response
44

55
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError
6-
from cycode.cyclient import models
6+
from cycode.cyclient import config, models
77
from cycode.cyclient.cycode_client import CycodeClient
88

99

@@ -13,6 +13,11 @@ class AuthClient:
1313
def __init__(self) -> None:
1414
self.cycode_client = CycodeClient()
1515

16+
@staticmethod
17+
def build_login_url(code_challenge: str, session_id: str) -> str:
18+
query_params = {'source': 'cycode_cli', 'code_challenge': code_challenge, 'session_id': session_id}
19+
return Request(url=f'{config.cycode_app_url}/account/sign-in', params=query_params).prepare().url
20+
1621
def start_session(self, code_challenge: str) -> models.AuthenticationSession:
1722
path = f'{self.AUTH_CONTROLLER_PATH}/start'
1823
body = {'code_challenge': code_challenge}

cycode/cyclient/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
cycode_api_url = consts.DEFAULT_CYCODE_API_URL
1515

1616

17+
cycode_app_url = configuration_manager.get_cycode_app_url()
18+
if not is_valid_url(cycode_app_url):
19+
logger.warning(
20+
'Invalid Cycode APP URL: %s, using default value (%s)', cycode_app_url, consts.DEFAULT_CYCODE_APP_URL
21+
)
22+
cycode_app_url = consts.DEFAULT_CYCODE_APP_URL
23+
24+
1725
def _is_on_premise_installation(cycode_domain: str) -> bool:
1826
return not cycode_api_url.endswith(cycode_domain)
1927

cycode/cyclient/cycode_client_base.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import os
22
import platform
33
import ssl
4-
from typing import Callable, ClassVar, Dict, Optional
4+
from typing import TYPE_CHECKING, Callable, ClassVar, Dict, Optional
55

66
import requests
77
from requests import Response, exceptions
88
from requests.adapters import HTTPAdapter
9+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_random_exponential
910

1011
from cycode.cli.exceptions.custom_exceptions import (
1112
HttpUnauthorizedError,
@@ -19,6 +20,9 @@
1920
from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id
2021
from cycode.cyclient.logger import logger
2122

23+
if TYPE_CHECKING:
24+
from tenacity import RetryCallState
25+
2226

2327
class SystemStorageSslContext(HTTPAdapter):
2428
def init_poolmanager(self, *args, **kwargs) -> None:
@@ -45,6 +49,47 @@ def _get_request_function() -> Callable:
4549
return session.request
4650

4751

52+
_REQUEST_ERRORS_TO_RETRY = (
53+
RequestTimeout,
54+
RequestConnectionError,
55+
exceptions.ChunkedEncodingError,
56+
exceptions.ContentDecodingError,
57+
)
58+
_RETRY_MAX_ATTEMPTS = 3
59+
_RETRY_STOP_STRATEGY = stop_after_attempt(_RETRY_MAX_ATTEMPTS)
60+
_RETRY_WAIT_STRATEGY = wait_random_exponential(multiplier=1, min=2, max=10)
61+
62+
63+
def _retry_before_sleep(retry_state: 'RetryCallState') -> None:
64+
exception_name = 'None'
65+
if retry_state.outcome.failed:
66+
exception = retry_state.outcome.exception()
67+
exception_name = f'{exception.__class__.__name__}'
68+
69+
logger.debug(
70+
'Retrying request after error: %s. Attempt %s of %s. Upcoming sleep: %s',
71+
exception_name,
72+
retry_state.attempt_number,
73+
_RETRY_MAX_ATTEMPTS,
74+
retry_state.upcoming_sleep,
75+
)
76+
77+
78+
def _should_retry_exception(exception: BaseException) -> bool:
79+
if 'PYTEST_CURRENT_TEST' in os.environ:
80+
# We are running under pytest, don't retry
81+
return False
82+
83+
# Don't retry client errors (400, 401, etc.)
84+
if isinstance(exception, RequestHttpError):
85+
return not exception.status_code < 500
86+
87+
is_request_error = isinstance(exception, _REQUEST_ERRORS_TO_RETRY)
88+
is_server_error = isinstance(exception, RequestHttpError) and exception.status_code >= 500
89+
90+
return is_request_error or is_server_error
91+
92+
4893
class CycodeClientBase:
4994
MANDATORY_HEADERS: ClassVar[Dict[str, str]] = {
5095
'User-Agent': get_cli_user_agent(),
@@ -72,6 +117,13 @@ def put(self, url_path: str, body: Optional[dict] = None, headers: Optional[dict
72117
def get(self, url_path: str, headers: Optional[dict] = None, **kwargs) -> Response:
73118
return self._execute(method='get', endpoint=url_path, headers=headers, **kwargs)
74119

120+
@retry(
121+
retry=retry_if_exception(_should_retry_exception),
122+
stop=_RETRY_STOP_STRATEGY,
123+
wait=_RETRY_WAIT_STRATEGY,
124+
reraise=True,
125+
before_sleep=_retry_before_sleep,
126+
)
75127
def _execute(
76128
self,
77129
method: str,

poetry.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pyjwt = ">=2.8.0,<3.0"
4141
rich = ">=13.9.4, <14"
4242
patch-ng = "1.18.1"
4343
typer = "^0.15.2"
44+
tenacity = ">=9.0.0,<9.1.0"
4445

4546
[tool.poetry.group.test.dependencies]
4647
mock = ">=4.0.3,<4.1.0"

0 commit comments

Comments
 (0)