From 9f9aa4f4ac0d0b9c5ef09af120171d9ab110c219 Mon Sep 17 00:00:00 2001 From: "Michael E. Karpeles" Date: Thu, 11 Jun 2026 12:55:24 -0600 Subject: [PATCH 1/2] feat(proxy): add proxy support to AmazonCreatorsApi Fixes two independent gaps that prevented routing traffic through an HTTP proxy: 1. AmazonCreatorsApi.__init__ now accepts a `proxy` URL parameter and passes it to ApiClient via a Configuration object. RESTClientObject already used urllib3.ProxyManager when configuration.proxy was set; this just wires the public API through to it. 2. OAuth2TokenManager.refresh_token() previously called requests.post() directly, bypassing any proxy configuration. It now creates a requests.Session and sets .proxies when a proxy URL is provided, ensuring token refresh on cold cache also routes through the proxy. Closes #149 Co-authored-by: Claude (claude-sonnet-4-6) --- amazon_creatorsapi/api.py | 8 +++ creatorsapi_python_sdk/api_client.py | 4 +- .../auth/oauth2_token_manager.py | 14 ++-- tests/amazon_creatorsapi/api_test.py | 28 ++++++++ .../oauth2_token_manager_test.py | 72 +++++++++++++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 tests/amazon_creatorsapi/oauth2_token_manager_test.py diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index 70dc472..5c332d4 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -16,6 +16,7 @@ from amazon_creatorsapi.errors import ItemsNotFoundError from creatorsapi_python_sdk.api.default_api import DefaultApi from creatorsapi_python_sdk.api_client import ApiClient +from creatorsapi_python_sdk.configuration import Configuration from creatorsapi_python_sdk.exceptions import ApiException from creatorsapi_python_sdk.models.get_browse_nodes_request_content import ( GetBrowseNodesRequestContent, @@ -58,6 +59,8 @@ class AmazonCreatorsApi: country: Country code (e.g., "ES", "US"). Used to determine marketplace. marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. throttling: Wait time in seconds between API calls. Defaults to 1 second. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + Applied to both regular API calls and OAuth2 token refresh. Raises: InvalidArgumentError: If neither country nor marketplace is provided. @@ -83,6 +86,7 @@ def __init__( country: CountryCode | None = None, marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, + proxy: str | None = None, ) -> None: """Initialize the Amazon Creators API client.""" self._credential_id = credential_id @@ -95,7 +99,11 @@ def __init__( # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) + configuration = Configuration() + configuration.proxy = proxy + self._api_client = ApiClient( + configuration=configuration, credential_id=credential_id, credential_secret=credential_secret, version=version, diff --git a/creatorsapi_python_sdk/api_client.py b/creatorsapi_python_sdk/api_client.py index 41f2b17..26bc802 100644 --- a/creatorsapi_python_sdk/api_client.py +++ b/creatorsapi_python_sdk/api_client.py @@ -384,7 +384,9 @@ def call_api( self.credential_id, self.credential_secret, self.version, self.auth_endpoint ) - self._token_manager = OAuth2TokenManager(config) + proxy = self.configuration.proxy + proxies = {"http": proxy, "https": proxy} if proxy else None + self._token_manager = OAuth2TokenManager(config, proxies=proxies) # Get token (will use cached token if valid) token = self._token_manager.get_token() # Add Authorization headers - Version only for v2.x diff --git a/creatorsapi_python_sdk/auth/oauth2_token_manager.py b/creatorsapi_python_sdk/auth/oauth2_token_manager.py index 7a730fa..65b01d8 100644 --- a/creatorsapi_python_sdk/auth/oauth2_token_manager.py +++ b/creatorsapi_python_sdk/auth/oauth2_token_manager.py @@ -30,13 +30,15 @@ class OAuth2TokenManager: """Manages OAuth2 token lifecycle including acquisition, caching, and automatic refresh""" - def __init__(self, config): + def __init__(self, config, proxies=None): """ Creates an OAuth2TokenManager instance - + :param config: The OAuth2Config instance + :param proxies: Optional dict of proxy URLs, e.g. {"http": "http://proxy:3128", "https": "http://proxy:3128"} """ self.config = config + self.proxies = proxies self.access_token = None self.expires_at = None @@ -67,6 +69,10 @@ def refresh_token(self): :raises Exception: If token refresh fails """ try: + session = requests.Session() + if self.proxies: + session.proxies.update(self.proxies) + if self.config.is_lwa(): # LWA (v3.x) uses JSON body request_data = { @@ -76,7 +82,7 @@ def refresh_token(self): 'scope': self.config.get_scope() } headers = {'Content-Type': 'application/json'} - response = requests.post( + response = session.post( self.config.get_cognito_endpoint(), json=request_data, headers=headers @@ -90,7 +96,7 @@ def refresh_token(self): 'scope': self.config.get_scope() } headers = {'Content-Type': 'application/x-www-form-urlencoded'} - response = requests.post( + response = session.post( self.config.get_cognito_endpoint(), data=request_data, headers=headers diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index c471f51..9ffb7b2 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -99,6 +99,34 @@ def test_init_no_country_or_marketplace(self) -> None: tag=self.tag, ) + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_init_with_proxy(self, mock_client: MagicMock) -> None: + """Test that proxy URL is passed through to ApiClient configuration.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + proxy=proxy_url, + ) + call_kwargs = mock_client.call_args.kwargs + self.assertEqual(call_kwargs["configuration"].proxy, proxy_url) + + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_init_without_proxy(self, mock_client: MagicMock) -> None: + """Test that configuration.proxy is None when no proxy is provided.""" + AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + ) + call_kwargs = mock_client.call_args.kwargs + self.assertIsNone(call_kwargs["configuration"].proxy) + @mock.patch("amazon_creatorsapi.api.ApiClient") def test_throttling_disabled(self, _mock_client: MagicMock) -> None: """Test that API call is not delayed when throttling is 0.""" diff --git a/tests/amazon_creatorsapi/oauth2_token_manager_test.py b/tests/amazon_creatorsapi/oauth2_token_manager_test.py new file mode 100644 index 0000000..e0ece16 --- /dev/null +++ b/tests/amazon_creatorsapi/oauth2_token_manager_test.py @@ -0,0 +1,72 @@ +"""Unit tests for OAuth2TokenManager proxy support.""" + +from __future__ import annotations + +import unittest +from unittest import mock +from unittest.mock import MagicMock, patch + +from creatorsapi_python_sdk.auth.oauth2_config import OAuth2Config +from creatorsapi_python_sdk.auth.oauth2_token_manager import OAuth2TokenManager + + +def _make_config(version: str = "2.2") -> OAuth2Config: + return OAuth2Config( + credential_id="test_id", + credential_secret="test_secret", + version=version, + auth_endpoint=None, + ) + + +def _mock_token_response() -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"access_token": "tok123", "expires_in": 3600} + return resp + + +class TestOAuth2TokenManagerProxy(unittest.TestCase): + """Tests that OAuth2TokenManager routes token refresh through the proxy.""" + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_sets_proxies_on_session(self, mock_session_cls: MagicMock) -> None: + """When proxies are provided, Session.proxies.update is called with them.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + proxies = {"http": proxy_url, "https": proxy_url} + + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config(), proxies=proxies) + manager.refresh_token() + + mock_session.proxies.update.assert_called_once_with(proxies) + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_no_proxy_skips_proxies_update(self, mock_session_cls: MagicMock) -> None: + """When no proxy is configured, Session.proxies.update is not called.""" + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config()) + manager.refresh_token() + + mock_session.proxies.update.assert_not_called() + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_lwa_sets_proxies_on_session(self, mock_session_cls: MagicMock) -> None: + """Proxy is also applied for LWA (v3.x) token refresh.""" + proxy_url = "http://proxy.example.com:3128" + proxies = {"http": proxy_url, "https": proxy_url} + + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config(version="3.1"), proxies=proxies) + manager.refresh_token() + + mock_session.proxies.update.assert_called_once_with(proxies) From c1c38bcf8b683b4da3551ae54e153d0bee431cdd Mon Sep 17 00:00:00 2001 From: "Michael E. Karpeles (Mek)" Date: Wed, 24 Jun 2026 22:43:19 -0600 Subject: [PATCH 2/2] fix(proxy): extract credentials from proxy URL for urllib3 HTTPS CONNECT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit urllib3.ProxyManager ignores userinfo embedded in the proxy URL when establishing HTTPS CONNECT tunnels — it only reads the netloc for the tunnel destination, not the proxy auth headers. The result is a 407 Proxy Authentication Required on every HTTPS API call through an authenticated Squid proxy (e.g. IA's http-proxy.us.archive.org). Fix: before handing the URL to ProxyManager, check whether configuration.proxy_headers is already set (caller-supplied). If not, parse the URL, extract username:password, build the Proxy-Authorization header via urllib3.make_headers(proxy_basic_auth=) and strip the credentials from the URL. The full credentialed URL is preserved in configuration.proxy so the requests-based OAuth2 token leg (api_client.py) continues to work unchanged. Confirmed via proxy_test.py on ol-home0: Approach A (URL-only): 407 Proxy Authentication Required Approach B (proxy_headers): 400 from creatorsapi.amazon (connected) --- creatorsapi_python_sdk/rest.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/creatorsapi_python_sdk/rest.py b/creatorsapi_python_sdk/rest.py index d41d019..be7c135 100644 --- a/creatorsapi_python_sdk/rest.py +++ b/creatorsapi_python_sdk/rest.py @@ -110,8 +110,28 @@ def __init__(self, configuration) -> None: pool_args["headers"] = configuration.proxy_headers self.pool_manager = SOCKSProxyManager(**pool_args) else: - pool_args["proxy_url"] = configuration.proxy - pool_args["proxy_headers"] = configuration.proxy_headers + proxy_url = configuration.proxy + proxy_headers = configuration.proxy_headers + # urllib3 ProxyManager ignores credentials embedded in the + # proxy URL for HTTPS CONNECT tunneling — they must be passed + # via proxy_headers instead. Extract them here so callers can + # pass a plain "http://user:pass@host:port" URL and get correct + # CONNECT auth without any extra configuration. + if proxy_headers is None: + from urllib.parse import urlparse + _parsed = urlparse(proxy_url) + if _parsed.username: + proxy_headers = urllib3.make_headers( + proxy_basic_auth=( + f"{_parsed.username}:{_parsed.password}" + ) + ) + proxy_url = ( + f"{_parsed.scheme}://" + f"{_parsed.hostname}:{_parsed.port}" + ) + pool_args["proxy_url"] = proxy_url + pool_args["proxy_headers"] = proxy_headers self.pool_manager = urllib3.ProxyManager(**pool_args) else: self.pool_manager = urllib3.PoolManager(**pool_args)