diff --git a/src/sentry_config.py b/src/sentry_config.py index 30678da..8402577 100644 --- a/src/sentry_config.py +++ b/src/sentry_config.py @@ -3,8 +3,8 @@ import base64 import logging import os +import time from configparser import ConfigParser -from functools import lru_cache import requests @@ -15,6 +15,33 @@ SENTRY_CONFIG_API_URL = ( "https://api.github.com/repos/{owner}/.sentry/contents/sentry_config.ini" ) +GITHUB_API_TIMEOUT_SECONDS = float(os.environ.get("GITHUB_API_TIMEOUT_SECONDS", "10")) +GITHUB_API_MAX_RETRIES = int(os.environ.get("GITHUB_API_MAX_RETRIES", "2")) +GITHUB_API_RETRY_BACKOFF_SECONDS = float( + os.environ.get("GITHUB_API_RETRY_BACKOFF_SECONDS", "0.5") +) + + +def _github_get_with_retries(url: str, headers: dict[str, str]) -> requests.Response: + total_attempts = GITHUB_API_MAX_RETRIES + 1 + for attempt in range(1, total_attempts + 1): + try: + return requests.get( + url, + headers=headers, + timeout=GITHUB_API_TIMEOUT_SECONDS, + ) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + if attempt == total_attempts: + raise + wait_seconds = GITHUB_API_RETRY_BACKOFF_SECONDS * (2 ** (attempt - 1)) + logger.warning( + "GitHub API request failed; retrying in %.1fs (%s/%s)", + wait_seconds, + attempt + 1, + total_attempts, + ) + time.sleep(wait_seconds) def fetch_dsn_for_github_org(org: str, token: str) -> str: @@ -27,7 +54,7 @@ def fetch_dsn_for_github_org(org: str, token: str) -> str: api_url = SENTRY_CONFIG_API_URL.replace("{owner}", org) # - Get meta about sentry_config.ini file - resp = requests.get(api_url, headers=headers) + resp = _github_get_with_retries(api_url, headers) resp.raise_for_status() meta = resp.json() diff --git a/tests/test_sentry_config_file.py b/tests/test_sentry_config_file.py index db95aa4..76e10a4 100644 --- a/tests/test_sentry_config_file.py +++ b/tests/test_sentry_config_file.py @@ -1,10 +1,16 @@ from __future__ import annotations +from unittest.mock import Mock +from unittest.mock import patch from unittest import TestCase +import requests import responses from src.sentry_config import fetch_dsn_for_github_org +from src.sentry_config import GITHUB_API_MAX_RETRIES +from src.sentry_config import GITHUB_API_RETRY_BACKOFF_SECONDS +from src.sentry_config import GITHUB_API_TIMEOUT_SECONDS from src.sentry_config import SENTRY_CONFIG_API_URL as api_url expected_dsn = ( @@ -55,3 +61,39 @@ def test_file_missing(self) -> None: def test_bad_contents(self) -> None: pass + + @patch("src.sentry_config.time.sleep") + @patch("src.sentry_config.requests.get") + def test_fetch_retries_connection_error_once( + self, + mock_get: Mock, + mock_sleep: Mock, + ) -> None: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = sentry_config_file_meta + mock_get.side_effect = [ + requests.exceptions.ConnectionError("connection reset"), + response, + ] + + assert fetch_dsn_for_github_org(org, token) == expected_dsn + assert mock_get.call_count == 2 + for call in mock_get.call_args_list: + assert call.kwargs["timeout"] == GITHUB_API_TIMEOUT_SECONDS + mock_sleep.assert_called_once_with(GITHUB_API_RETRY_BACKOFF_SECONDS) + + @patch("src.sentry_config.time.sleep") + @patch("src.sentry_config.requests.get") + def test_fetch_raises_after_connection_error_retries_exhausted( + self, + mock_get: Mock, + mock_sleep: Mock, + ) -> None: + mock_get.side_effect = requests.exceptions.ConnectionError("connection reset") + + with self.assertRaises(requests.exceptions.ConnectionError): + fetch_dsn_for_github_org(org, token) + + assert mock_get.call_count == GITHUB_API_MAX_RETRIES + 1 + assert mock_sleep.call_count == GITHUB_API_MAX_RETRIES