diff --git a/src/zenrows/client.py b/src/zenrows/client.py index 851ab4e..5f7bd2f 100644 --- a/src/zenrows/client.py +++ b/src/zenrows/client.py @@ -16,6 +16,7 @@ import numbers import os import random +import re import sys import time import warnings @@ -42,6 +43,9 @@ DEFAULT_RETRY_MAX_WAIT = 30.0 DEFAULT_RETRY_BUDGET = 90.0 +# The API key as it appears in a request URL. +_APIKEY_IN_URL = re.compile(r"apikey=[^&\s]*") + # Clock for the retry budget; tests replace it. _monotonic = time.monotonic @@ -125,6 +129,29 @@ def _failed_to_connect(error: requests.ConnectionError) -> bool: return isinstance(reason, ConnectTimeoutError) # NewConnectionError is one too +def _redact_apikey(error: BaseException) -> None: + """Replace the API key with REDACTED in the messages of `error` and of the + exceptions it wraps. A failed connection names the request URL, which + carries the key in `apikey`, so logging the error would log the key. The + exceptions stay the same objects.""" + seen: set[int] = set() + pending = [error] + while pending: + exc = pending.pop() + if id(exc) in seen: + continue + seen.add(id(exc)) + exc.args = tuple( + _APIKEY_IN_URL.sub("apikey=REDACTED", arg) if isinstance(arg, str) else arg + for arg in exc.args + ) + pending += [ + inner + for inner in (*exc.args, exc.__cause__, exc.__context__) + if isinstance(inner, BaseException) + ] + + def _from_target(status: int, params: dict) -> bool: """True when `status` may be the target site's own answer, which another try cannot change: any status with `original_status`, or one listed in @@ -607,7 +634,7 @@ def _worker( final_headers["Connection"] = None final_headers.update(headers) - send = partial( + request = partial( self.requests_session.request, method, self.api_url, @@ -616,6 +643,14 @@ def _worker( data=data, **kwargs, ) + + def send() -> requests.Response: + try: + return request() + except requests.RequestException as error: + _redact_apikey(error) + raise + if not self.retries or not _can_send_again(method, data, kwargs): return send() started = _monotonic() diff --git a/tests/test_client.py b/tests/test_client.py index fff8bde..70a11c3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,8 @@ +import socket +import traceback from unittest import TestCase, mock +import requests import urllib3 from requests import Session @@ -152,3 +155,19 @@ def test_put_with_data(self, mock_request): "key2": "value2", }, ) + + def test_a_connection_error_hides_the_api_key(self): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + closed = f"http://127.0.0.1:{sock.getsockname()[1]}/v1/" + for retries in (0, 1): + client = ZenRowsClient("secret+key/=", base_url=closed, retries=retries) + with ( + mock.patch("time.sleep"), + self.assertRaises(requests.ConnectionError) as raised, + ): + client.get(url) + + printed = "".join(traceback.format_exception(raised.exception)) + self.assertIn("apikey=REDACTED", str(raised.exception)) + self.assertNotIn("secret", printed)