From dfed9e2357823af41c2c8b4a07a4280862935c6e Mon Sep 17 00:00:00 2001 From: Bogdan Novosad Date: Sat, 14 Nov 2026 15:21:03 +0100 Subject: [PATCH] fix(client): keep the API key out of connection errors The key travels in the apikey query parameter, and a requests connection error (refused connection, DNS failure, connect timeout) names the request URL, so printing or logging it showed the key. The client now replaces it with REDACTED in the messages of the error and of the exceptions it wraps; the exception objects and their types stay the same. --- src/zenrows/client.py | 37 ++++++++++++++++++++++++++++++++++++- tests/test_client.py | 19 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) 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)