From 4f8c58c8bb323d2fe1f2ea0fee2f059a0199e3df Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Fri, 14 Aug 2026 20:19:04 -0400 Subject: [PATCH 1/7] Adjust HTTP client request handling --- codegen/http/generate_http.py | 53 +++- .../http/src/turnkey_http/generated/client.py | 54 ++++- packages/http/tests/test_redirects.py | 229 ++++++++++++++++++ 3 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 packages/http/tests/test_redirects.py diff --git a/codegen/http/generate_http.py b/codegen/http/generate_http.py index 3bc57b8..0b4d959 100644 --- a/codegen/http/generate_http.py +++ b/codegen/http/generate_http.py @@ -92,6 +92,42 @@ def serialize_value(value): serialized = serialize_value(body) return json.dumps(serialized) + def _url_origin(self, url: str) -> str: + parts = urlsplit(url) + scheme = parts.scheme.lower() + port = parts.port if parts.port is not None else {"http": 80, "https": 443}.get(scheme) + host = (parts.hostname or "").lower() + return f"{scheme}://{host}:{port}" + + def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Response: + origin = self._url_origin(url) + current_url = url + for _ in range(MAX_REDIRECTS): + response = requests.post( + current_url, + headers=headers, + data=data, + timeout=self.default_timeout, + allow_redirects=False + ) + if response.status_code not in (301, 302, 303, 307, 308): + return response + + location = response.headers.get("Location") + if response.status_code in (307, 308) and location: + next_url = urljoin(current_url, location) + try: + if self._url_origin(next_url) == origin: + current_url = next_url + continue + except ValueError: + pass + raise requests.RequestException( + f"Not following redirect ({response.status_code}) to {location!r}" + ) + + raise requests.TooManyRedirects(f"Exceeded {MAX_REDIRECTS} redirects") + def _request(self, url: str, body: Dict[str, Any], response_type: type) -> Any: \"\"\"Make a request to the Turnkey API. @@ -117,12 +153,7 @@ def _request(self, url: str, body: Dict[str, Any], response_type: type) -> Any: } try: - response = requests.post( - full_url, - headers=headers, - data=body_str, - timeout=self.default_timeout - ) + response = self._post(full_url, headers, body_str) except requests.RequestException as exc: raise TurnkeyNetworkError( "Request failed", @@ -253,12 +284,7 @@ def send_signed_request(self, signed_request: SignedRequest, response_type: type } try: - response = requests.post( - signed_request.url, - headers=headers, - data=signed_request.body, - timeout=self.default_timeout - ) + response = self._post(signed_request.url, headers, signed_request.body) except requests.RequestException as exc: raise TurnkeyNetworkError( "Signed request failed", @@ -543,12 +569,13 @@ def main(): # Build full output output = f"{COMMENT_HEADER}\n\n" - output += "import json\nimport time\nfrom typing import Any, Callable, Dict, Optional, TypeVar, overload\nimport requests\n" + output += "import json\nimport time\nfrom typing import Any, Callable, Dict, Optional, TypeVar, overload\nfrom urllib.parse import urljoin, urlsplit\nimport requests\n" output += "from turnkey_api_key_stamper import ApiKeyStamper\n" output += "from turnkey_sdk_types import *\n" output += "from ..version import VERSION\n\n" output += "T = TypeVar('T')\n\n" output += f"TERMINAL_ACTIVITY_STATUSES = {TERMINAL_ACTIVITY_STATUSES}\n\n" + output += "MAX_REDIRECTS = 5\n\n" output += client_code # Ensure output directory exists diff --git a/packages/http/src/turnkey_http/generated/client.py b/packages/http/src/turnkey_http/generated/client.py index 94e9b51..5124d01 100644 --- a/packages/http/src/turnkey_http/generated/client.py +++ b/packages/http/src/turnkey_http/generated/client.py @@ -3,6 +3,7 @@ import json import time from typing import Any, Callable, Dict, Optional, TypeVar, overload +from urllib.parse import urljoin, urlsplit import requests from turnkey_api_key_stamper import ApiKeyStamper from turnkey_sdk_types import * @@ -17,6 +18,8 @@ "ACTIVITY_STATUS_REJECTED", ] +MAX_REDIRECTS = 5 + class TurnkeyClient: """Turnkey API HTTP client with auto-generated methods.""" @@ -71,6 +74,46 @@ def serialize_value(value): serialized = serialize_value(body) return json.dumps(serialized) + def _url_origin(self, url: str) -> str: + parts = urlsplit(url) + scheme = parts.scheme.lower() + port = ( + parts.port + if parts.port is not None + else {"http": 80, "https": 443}.get(scheme) + ) + host = (parts.hostname or "").lower() + return f"{scheme}://{host}:{port}" + + def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Response: + origin = self._url_origin(url) + current_url = url + for _ in range(MAX_REDIRECTS): + response = requests.post( + current_url, + headers=headers, + data=data, + timeout=self.default_timeout, + allow_redirects=False, + ) + if response.status_code not in (301, 302, 303, 307, 308): + return response + + location = response.headers.get("Location") + if response.status_code in (307, 308) and location: + next_url = urljoin(current_url, location) + try: + if self._url_origin(next_url) == origin: + current_url = next_url + continue + except ValueError: + pass + raise requests.RequestException( + f"Not following redirect ({response.status_code}) to {location!r}" + ) + + raise requests.TooManyRedirects(f"Exceeded {MAX_REDIRECTS} redirects") + def _request(self, url: str, body: Dict[str, Any], response_type: type) -> Any: """Make a request to the Turnkey API. @@ -96,9 +139,7 @@ def _request(self, url: str, body: Dict[str, Any], response_type: type) -> Any: } try: - response = requests.post( - full_url, headers=headers, data=body_str, timeout=self.default_timeout - ) + response = self._post(full_url, headers, body_str) except requests.RequestException as exc: raise TurnkeyNetworkError( "Request failed", None, TurnkeyErrorCodes.NETWORK_ERROR, str(exc) @@ -239,12 +280,7 @@ def send_signed_request( } try: - response = requests.post( - signed_request.url, - headers=headers, - data=signed_request.body, - timeout=self.default_timeout, - ) + response = self._post(signed_request.url, headers, signed_request.body) except requests.RequestException as exc: raise TurnkeyNetworkError( "Signed request failed", None, TurnkeyErrorCodes.NETWORK_ERROR, str(exc) diff --git a/packages/http/tests/test_redirects.py b/packages/http/tests/test_redirects.py new file mode 100644 index 0000000..016e8cd --- /dev/null +++ b/packages/http/tests/test_redirects.py @@ -0,0 +1,229 @@ +import json +from unittest.mock import patch + +import pytest +from turnkey_api_key_stamper import TStamp +from turnkey_http.generated.client import TurnkeyClient +from turnkey_sdk_types import RequestType, SignedRequest, TurnkeyNetworkError + +BASE_URL = "https://api.example.com" +ENDPOINT = "/public/v1/query/test" +STAMP_HEADER = "X-Stamp" +STAMP_VALUE = "stamp-value" + + +class StaticStamper: + stamp_header_name = STAMP_HEADER + + def stamp(self, content): + return TStamp(stamp_header_name=STAMP_HEADER, stamp_header_value=STAMP_VALUE) + + +class FakeResponse: + def __init__(self, status_code, headers=None, payload=None): + self.status_code = status_code + self.headers = headers or {} + self.ok = status_code < 400 + self._payload = payload if payload is not None else {} + self.text = json.dumps(self._payload) + self.reason = "" + + def json(self): + return self._payload + + +def redirect(status_code, location): + return FakeResponse(status_code, headers={"Location": location}) + + +@pytest.fixture +def transport_client(): + return TurnkeyClient( + base_url=BASE_URL, stamper=StaticStamper(), organization_id="org-id" + ) + + +@pytest.fixture +def calls(): + return [] + + +def fake_post(responses, calls): + def post(url, headers=None, data=None, timeout=None, allow_redirects=True): + calls.append( + { + "url": url, + "headers": headers, + "data": data, + "allow_redirects": allow_redirects, + } + ) + return responses.pop(0) + + return post + + +def run_request(transport_client, responses, calls): + with patch( + "turnkey_http.generated.client.requests.post", + new=fake_post(responses, calls), + ): + return transport_client._request(ENDPOINT, {"organizationId": "org-id"}, dict) + + +def run_signed_request(transport_client, responses, calls, url=BASE_URL + ENDPOINT): + signed_request = SignedRequest( + url=url, + body='{"organizationId": "org-id"}', + stamp=TStamp(stamp_header_name=STAMP_HEADER, stamp_header_value=STAMP_VALUE), + type=RequestType.QUERY, + ) + with patch( + "turnkey_http.generated.client.requests.post", + new=fake_post(responses, calls), + ): + return transport_client.send_signed_request(signed_request) + + +def test_request_disables_automatic_redirects(transport_client, calls): + run_request(transport_client, [FakeResponse(200, payload={"result": "ok"})], calls) + + assert len(calls) == 1 + assert calls[0]["allow_redirects"] is False + + +@pytest.mark.parametrize("status_code", [307, 308]) +def test_same_origin_redirect_is_followed(transport_client, calls, status_code): + responses = [ + redirect(status_code, BASE_URL + "/public/v1/query/other"), + FakeResponse(200, payload={"result": "ok"}), + ] + + result = run_request(transport_client, responses, calls) + + assert result == {"result": "ok"} + assert len(calls) == 2 + assert calls[1]["url"] == BASE_URL + "/public/v1/query/other" + assert calls[1]["headers"][STAMP_HEADER] == STAMP_VALUE + assert calls[1]["data"] == calls[0]["data"] + assert calls[1]["allow_redirects"] is False + + +def test_relative_redirect_is_followed(transport_client, calls): + responses = [ + redirect(307, "/public/v1/query/other"), + FakeResponse(200, payload={"result": "ok"}), + ] + + result = run_request(transport_client, responses, calls) + + assert result == {"result": "ok"} + assert calls[1]["url"] == BASE_URL + "/public/v1/query/other" + + +def test_default_port_redirect_is_followed(transport_client, calls): + responses = [ + redirect(307, "https://api.example.com:443" + ENDPOINT), + FakeResponse(200, payload={"result": "ok"}), + ] + + result = run_request(transport_client, responses, calls) + + assert result == {"result": "ok"} + assert len(calls) == 2 + + +@pytest.mark.parametrize("status_code", [307, 308]) +def test_other_host_redirect_is_not_followed(transport_client, calls, status_code): + responses = [redirect(status_code, "https://other.example.com" + ENDPOINT)] + + with pytest.raises(TurnkeyNetworkError): + run_request(transport_client, responses, calls) + + assert len(calls) == 1 + + +def test_scheme_change_redirect_is_not_followed(transport_client, calls): + responses = [redirect(307, "http://api.example.com" + ENDPOINT)] + + with pytest.raises(TurnkeyNetworkError): + run_request(transport_client, responses, calls) + + assert len(calls) == 1 + + +def test_port_change_redirect_is_not_followed(transport_client, calls): + responses = [redirect(307, "https://api.example.com:8443" + ENDPOINT)] + + with pytest.raises(TurnkeyNetworkError): + run_request(transport_client, responses, calls) + + assert len(calls) == 1 + + +def test_redirect_chain_to_other_host_is_not_followed(transport_client, calls): + responses = [ + redirect(307, BASE_URL + "/public/v1/query/hop"), + redirect(308, "https://other.example.com" + ENDPOINT), + ] + + with pytest.raises(TurnkeyNetworkError): + run_request(transport_client, responses, calls) + + assert len(calls) == 2 + assert all(call["url"].startswith(BASE_URL + "/") for call in calls) + + +@pytest.mark.parametrize("status_code", [301, 302, 303]) +def test_other_redirect_statuses_are_not_followed(transport_client, calls, status_code): + responses = [redirect(status_code, BASE_URL + "/public/v1/query/other")] + + with pytest.raises(TurnkeyNetworkError): + run_request(transport_client, responses, calls) + + assert len(calls) == 1 + + +def test_redirect_without_location_is_not_followed(transport_client, calls): + responses = [FakeResponse(307)] + + with pytest.raises(TurnkeyNetworkError): + run_request(transport_client, responses, calls) + + assert len(calls) == 1 + + +def test_redirect_limit_is_enforced(transport_client, calls): + responses = [redirect(307, BASE_URL + ENDPOINT) for _ in range(6)] + + with pytest.raises(TurnkeyNetworkError): + run_request(transport_client, responses, calls) + + assert len(calls) == 5 + + +def test_send_signed_request_same_origin_redirect_is_followed(transport_client, calls): + responses = [ + redirect(307, BASE_URL + "/public/v1/query/other"), + FakeResponse(200, payload={"result": "ok"}), + ] + + result = run_signed_request(transport_client, responses, calls) + + assert result == {"result": "ok"} + assert len(calls) == 2 + assert calls[1]["url"] == BASE_URL + "/public/v1/query/other" + assert calls[1]["headers"][STAMP_HEADER] == STAMP_VALUE + assert calls[1]["data"] == calls[0]["data"] + + +@pytest.mark.parametrize("status_code", [307, 308]) +def test_send_signed_request_other_host_redirect_is_not_followed( + transport_client, calls, status_code +): + responses = [redirect(status_code, "https://other.example.com" + ENDPOINT)] + + with pytest.raises(TurnkeyNetworkError): + run_signed_request(transport_client, responses, calls) + + assert len(calls) == 1 From ea67b36a8747904b9eb7cc68a57a586abf623297 Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Fri, 14 Aug 2026 20:22:12 -0400 Subject: [PATCH 2/7] Fix formatting --- README.md | 7 ++----- packages/api-key-stamper/README.md | 6 ++---- packages/http/README.md | 7 ++----- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b00f420..0568dcc 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,13 @@ from turnkey_api_key_stamper import ApiKeyStamper, ApiKeyStamperConfig # Initialize stamper config = ApiKeyStamperConfig( - api_public_key="your-api-public-key", - api_private_key="your-api-private-key" + api_public_key="your-api-public-key", api_private_key="your-api-private-key" ) stamper = ApiKeyStamper(config) # Create client client = TurnkeyClient( - base_url="https://api.turnkey.com", - stamper=stamper, - organization_id="your-org-id" + base_url="https://api.turnkey.com", stamper=stamper, organization_id="your-org-id" ) # Make API calls diff --git a/packages/api-key-stamper/README.md b/packages/api-key-stamper/README.md index 3489aaf..c23e1d2 100644 --- a/packages/api-key-stamper/README.md +++ b/packages/api-key-stamper/README.md @@ -18,14 +18,12 @@ import json # Initialize the stamper with your API credentials config = ApiKeyStamperConfig( api_public_key="", - api_private_key="" + api_private_key="", ) stamper = ApiKeyStamper(config) # Create your request payload -payload = { - "organizationId": "" -} +payload = {"organizationId": ""} payload_str = json.dumps(payload) # Generate the authentication stamp diff --git a/packages/http/README.md b/packages/http/README.md index c3510ec..54182e1 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -22,16 +22,13 @@ from turnkey_api_key_stamper import ApiKeyStamper, ApiKeyStamperConfig # Initialize the stamper config = ApiKeyStamperConfig( - api_public_key="your-api-public-key", - api_private_key="your-api-private-key" + api_public_key="your-api-public-key", api_private_key="your-api-private-key" ) stamper = ApiKeyStamper(config) # Create the HTTP client client = TurnkeyClient( - base_url="https://api.turnkey.com", - stamper=stamper, - organization_id="your-org-id" + base_url="https://api.turnkey.com", stamper=stamper, organization_id="your-org-id" ) # Make API calls with typed methods From 285078d6de2793f97c45e5edb424f39cce875573 Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Fri, 14 Aug 2026 20:27:24 -0400 Subject: [PATCH 3/7] Trim HTTP client tests --- packages/http/tests/test_redirects.py | 215 +++++++------------------- 1 file changed, 52 insertions(+), 163 deletions(-) diff --git a/packages/http/tests/test_redirects.py b/packages/http/tests/test_redirects.py index 016e8cd..b2050bb 100644 --- a/packages/http/tests/test_redirects.py +++ b/packages/http/tests/test_redirects.py @@ -8,23 +8,20 @@ BASE_URL = "https://api.example.com" ENDPOINT = "/public/v1/query/test" -STAMP_HEADER = "X-Stamp" -STAMP_VALUE = "stamp-value" +STAMP = TStamp(stamp_header_name="X-Stamp", stamp_header_value="stamp-value") class StaticStamper: - stamp_header_name = STAMP_HEADER - def stamp(self, content): - return TStamp(stamp_header_name=STAMP_HEADER, stamp_header_value=STAMP_VALUE) + return STAMP class FakeResponse: - def __init__(self, status_code, headers=None, payload=None): + def __init__(self, status_code, location=None, payload=None): self.status_code = status_code - self.headers = headers or {} + self.headers = {"Location": location} if location else {} self.ok = status_code < 400 - self._payload = payload if payload is not None else {} + self._payload = payload or {} self.text = json.dumps(self._payload) self.reason = "" @@ -32,15 +29,9 @@ def json(self): return self._payload -def redirect(status_code, location): - return FakeResponse(status_code, headers={"Location": location}) - - @pytest.fixture -def transport_client(): - return TurnkeyClient( - base_url=BASE_URL, stamper=StaticStamper(), organization_id="org-id" - ) +def client(): + return TurnkeyClient(BASE_URL, StaticStamper(), "org-id") @pytest.fixture @@ -48,182 +39,80 @@ def calls(): return [] -def fake_post(responses, calls): - def post(url, headers=None, data=None, timeout=None, allow_redirects=True): - calls.append( - { - "url": url, - "headers": headers, - "data": data, - "allow_redirects": allow_redirects, - } - ) +def send(client, calls, responses, signed=False): + def post(url, headers, data, timeout, allow_redirects): + calls.append((url, headers, data, allow_redirects)) return responses.pop(0) - return post - - -def run_request(transport_client, responses, calls): - with patch( - "turnkey_http.generated.client.requests.post", - new=fake_post(responses, calls), - ): - return transport_client._request(ENDPOINT, {"organizationId": "org-id"}, dict) - - -def run_signed_request(transport_client, responses, calls, url=BASE_URL + ENDPOINT): - signed_request = SignedRequest( - url=url, - body='{"organizationId": "org-id"}', - stamp=TStamp(stamp_header_name=STAMP_HEADER, stamp_header_value=STAMP_VALUE), - type=RequestType.QUERY, - ) - with patch( - "turnkey_http.generated.client.requests.post", - new=fake_post(responses, calls), - ): - return transport_client.send_signed_request(signed_request) - - -def test_request_disables_automatic_redirects(transport_client, calls): - run_request(transport_client, [FakeResponse(200, payload={"result": "ok"})], calls) - - assert len(calls) == 1 - assert calls[0]["allow_redirects"] is False + with patch("turnkey_http.generated.client.requests.post", new=post): + if signed: + request = SignedRequest( + BASE_URL + ENDPOINT, + '{"organizationId": "org-id"}', + STAMP, + RequestType.QUERY, + ) + return client.send_signed_request(request) + return client._request(ENDPOINT, {"organizationId": "org-id"}, dict) +@pytest.mark.parametrize("signed", [False, True]) @pytest.mark.parametrize("status_code", [307, 308]) -def test_same_origin_redirect_is_followed(transport_client, calls, status_code): - responses = [ - redirect(status_code, BASE_URL + "/public/v1/query/other"), - FakeResponse(200, payload={"result": "ok"}), - ] +def test_cross_origin_redirect_is_not_followed(client, calls, signed, status_code): + responses = [FakeResponse(status_code, "https://other.example.com" + ENDPOINT)] - result = run_request(transport_client, responses, calls) + with pytest.raises(TurnkeyNetworkError): + send(client, calls, responses, signed) - assert result == {"result": "ok"} - assert len(calls) == 2 - assert calls[1]["url"] == BASE_URL + "/public/v1/query/other" - assert calls[1]["headers"][STAMP_HEADER] == STAMP_VALUE - assert calls[1]["data"] == calls[0]["data"] - assert calls[1]["allow_redirects"] is False + assert len(calls) == 1 + assert calls[0][3] is False -def test_relative_redirect_is_followed(transport_client, calls): +@pytest.mark.parametrize("signed", [False, True]) +def test_same_origin_redirect_preserves_request(client, calls, signed): responses = [ - redirect(307, "/public/v1/query/other"), + FakeResponse(307, "https://api.example.com:443/public/v1/query/other"), FakeResponse(200, payload={"result": "ok"}), ] - result = run_request(transport_client, responses, calls) + assert send(client, calls, responses, signed) == {"result": "ok"} + assert calls[1][0] == "https://api.example.com:443/public/v1/query/other" + assert calls[1][1:] == calls[0][1:] - assert result == {"result": "ok"} - assert calls[1]["url"] == BASE_URL + "/public/v1/query/other" - -def test_default_port_redirect_is_followed(transport_client, calls): +def test_redirect_chain_stays_on_original_origin(client, calls): responses = [ - redirect(307, "https://api.example.com:443" + ENDPOINT), - FakeResponse(200, payload={"result": "ok"}), + FakeResponse(307, BASE_URL + "/public/v1/query/hop"), + FakeResponse(308, "https://other.example.com" + ENDPOINT), ] - result = run_request(transport_client, responses, calls) - - assert result == {"result": "ok"} - assert len(calls) == 2 - - -@pytest.mark.parametrize("status_code", [307, 308]) -def test_other_host_redirect_is_not_followed(transport_client, calls, status_code): - responses = [redirect(status_code, "https://other.example.com" + ENDPOINT)] - - with pytest.raises(TurnkeyNetworkError): - run_request(transport_client, responses, calls) - - assert len(calls) == 1 - - -def test_scheme_change_redirect_is_not_followed(transport_client, calls): - responses = [redirect(307, "http://api.example.com" + ENDPOINT)] - with pytest.raises(TurnkeyNetworkError): - run_request(transport_client, responses, calls) - - assert len(calls) == 1 - + send(client, calls, responses) -def test_port_change_redirect_is_not_followed(transport_client, calls): - responses = [redirect(307, "https://api.example.com:8443" + ENDPOINT)] - - with pytest.raises(TurnkeyNetworkError): - run_request(transport_client, responses, calls) - - assert len(calls) == 1 - - -def test_redirect_chain_to_other_host_is_not_followed(transport_client, calls): - responses = [ - redirect(307, BASE_URL + "/public/v1/query/hop"), - redirect(308, "https://other.example.com" + ENDPOINT), + assert [call[0] for call in calls] == [ + BASE_URL + ENDPOINT, + BASE_URL + "/public/v1/query/hop", ] - with pytest.raises(TurnkeyNetworkError): - run_request(transport_client, responses, calls) - - assert len(calls) == 2 - assert all(call["url"].startswith(BASE_URL + "/") for call in calls) - - -@pytest.mark.parametrize("status_code", [301, 302, 303]) -def test_other_redirect_statuses_are_not_followed(transport_client, calls, status_code): - responses = [redirect(status_code, BASE_URL + "/public/v1/query/other")] +@pytest.mark.parametrize( + "location", + [ + "http://api.example.com" + ENDPOINT, + "https://api.example.com:8443" + ENDPOINT, + ], +) +def test_effective_origin_changes_are_not_followed(client, calls, location): with pytest.raises(TurnkeyNetworkError): - run_request(transport_client, responses, calls) + send(client, calls, [FakeResponse(307, location)]) assert len(calls) == 1 -def test_redirect_without_location_is_not_followed(transport_client, calls): - responses = [FakeResponse(307)] +def test_redirect_limit_is_enforced(client, calls): + responses = [FakeResponse(307, BASE_URL + ENDPOINT) for _ in range(6)] with pytest.raises(TurnkeyNetworkError): - run_request(transport_client, responses, calls) - - assert len(calls) == 1 - - -def test_redirect_limit_is_enforced(transport_client, calls): - responses = [redirect(307, BASE_URL + ENDPOINT) for _ in range(6)] - - with pytest.raises(TurnkeyNetworkError): - run_request(transport_client, responses, calls) + send(client, calls, responses) assert len(calls) == 5 - - -def test_send_signed_request_same_origin_redirect_is_followed(transport_client, calls): - responses = [ - redirect(307, BASE_URL + "/public/v1/query/other"), - FakeResponse(200, payload={"result": "ok"}), - ] - - result = run_signed_request(transport_client, responses, calls) - - assert result == {"result": "ok"} - assert len(calls) == 2 - assert calls[1]["url"] == BASE_URL + "/public/v1/query/other" - assert calls[1]["headers"][STAMP_HEADER] == STAMP_VALUE - assert calls[1]["data"] == calls[0]["data"] - - -@pytest.mark.parametrize("status_code", [307, 308]) -def test_send_signed_request_other_host_redirect_is_not_followed( - transport_client, calls, status_code -): - responses = [redirect(status_code, "https://other.example.com" + ENDPOINT)] - - with pytest.raises(TurnkeyNetworkError): - run_signed_request(transport_client, responses, calls) - - assert len(calls) == 1 From 443d03e838138de53416c0a74c9c1c1ca5561162 Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Fri, 14 Aug 2026 20:29:07 -0400 Subject: [PATCH 4/7] Simplify redirect handling --- codegen/http/generate_http.py | 4 ++-- packages/http/src/turnkey_http/generated/client.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codegen/http/generate_http.py b/codegen/http/generate_http.py index 0b4d959..f63fc8b 100644 --- a/codegen/http/generate_http.py +++ b/codegen/http/generate_http.py @@ -110,11 +110,11 @@ def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Respon timeout=self.default_timeout, allow_redirects=False ) - if response.status_code not in (301, 302, 303, 307, 308): + if response.status_code not in (307, 308): return response location = response.headers.get("Location") - if response.status_code in (307, 308) and location: + if location: next_url = urljoin(current_url, location) try: if self._url_origin(next_url) == origin: diff --git a/packages/http/src/turnkey_http/generated/client.py b/packages/http/src/turnkey_http/generated/client.py index 5124d01..203ba7c 100644 --- a/packages/http/src/turnkey_http/generated/client.py +++ b/packages/http/src/turnkey_http/generated/client.py @@ -96,11 +96,11 @@ def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Respon timeout=self.default_timeout, allow_redirects=False, ) - if response.status_code not in (301, 302, 303, 307, 308): + if response.status_code not in (307, 308): return response location = response.headers.get("Location") - if response.status_code in (307, 308) and location: + if location: next_url = urljoin(current_url, location) try: if self._url_origin(next_url) == origin: From ea9c669bfd4163a13425a0383a766e7a315712e7 Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Fri, 14 Aug 2026 20:41:54 -0400 Subject: [PATCH 5/7] Reduce HTTP client test coverage --- README.md | 7 ++-- packages/api-key-stamper/README.md | 6 ++-- packages/http/README.md | 7 ++-- packages/http/tests/test_redirects.py | 47 +++++++++------------------ pyproject.toml | 3 ++ 5 files changed, 32 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 0568dcc..b00f420 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,16 @@ from turnkey_api_key_stamper import ApiKeyStamper, ApiKeyStamperConfig # Initialize stamper config = ApiKeyStamperConfig( - api_public_key="your-api-public-key", api_private_key="your-api-private-key" + api_public_key="your-api-public-key", + api_private_key="your-api-private-key" ) stamper = ApiKeyStamper(config) # Create client client = TurnkeyClient( - base_url="https://api.turnkey.com", stamper=stamper, organization_id="your-org-id" + base_url="https://api.turnkey.com", + stamper=stamper, + organization_id="your-org-id" ) # Make API calls diff --git a/packages/api-key-stamper/README.md b/packages/api-key-stamper/README.md index c23e1d2..3489aaf 100644 --- a/packages/api-key-stamper/README.md +++ b/packages/api-key-stamper/README.md @@ -18,12 +18,14 @@ import json # Initialize the stamper with your API credentials config = ApiKeyStamperConfig( api_public_key="", - api_private_key="", + api_private_key="" ) stamper = ApiKeyStamper(config) # Create your request payload -payload = {"organizationId": ""} +payload = { + "organizationId": "" +} payload_str = json.dumps(payload) # Generate the authentication stamp diff --git a/packages/http/README.md b/packages/http/README.md index 54182e1..c3510ec 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -22,13 +22,16 @@ from turnkey_api_key_stamper import ApiKeyStamper, ApiKeyStamperConfig # Initialize the stamper config = ApiKeyStamperConfig( - api_public_key="your-api-public-key", api_private_key="your-api-private-key" + api_public_key="your-api-public-key", + api_private_key="your-api-private-key" ) stamper = ApiKeyStamper(config) # Create the HTTP client client = TurnkeyClient( - base_url="https://api.turnkey.com", stamper=stamper, organization_id="your-org-id" + base_url="https://api.turnkey.com", + stamper=stamper, + organization_id="your-org-id" ) # Make API calls with typed methods diff --git a/packages/http/tests/test_redirects.py b/packages/http/tests/test_redirects.py index b2050bb..31ac2b2 100644 --- a/packages/http/tests/test_redirects.py +++ b/packages/http/tests/test_redirects.py @@ -56,10 +56,17 @@ def post(url, headers, data, timeout, allow_redirects): return client._request(ENDPOINT, {"organizationId": "org-id"}, dict) -@pytest.mark.parametrize("signed", [False, True]) -@pytest.mark.parametrize("status_code", [307, 308]) -def test_cross_origin_redirect_is_not_followed(client, calls, signed, status_code): - responses = [FakeResponse(status_code, "https://other.example.com" + ENDPOINT)] +@pytest.mark.parametrize( + "signed,status_code,location", + [ + (False, 307, "https://other.example.com" + ENDPOINT), + (True, 308, "http://api.example.com" + ENDPOINT), + ], +) +def test_cross_origin_redirect_is_not_followed( + client, calls, signed, status_code, location +): + responses = [FakeResponse(status_code, location)] with pytest.raises(TurnkeyNetworkError): send(client, calls, responses, signed) @@ -68,15 +75,14 @@ def test_cross_origin_redirect_is_not_followed(client, calls, signed, status_cod assert calls[0][3] is False -@pytest.mark.parametrize("signed", [False, True]) -def test_same_origin_redirect_preserves_request(client, calls, signed): +def test_same_origin_redirect_preserves_request(client, calls): responses = [ - FakeResponse(307, "https://api.example.com:443/public/v1/query/other"), + FakeResponse(307, BASE_URL + "/public/v1/query/other"), FakeResponse(200, payload={"result": "ok"}), ] - assert send(client, calls, responses, signed) == {"result": "ok"} - assert calls[1][0] == "https://api.example.com:443/public/v1/query/other" + assert send(client, calls, responses) == {"result": "ok"} + assert calls[1][0] == BASE_URL + "/public/v1/query/other" assert calls[1][1:] == calls[0][1:] @@ -93,26 +99,3 @@ def test_redirect_chain_stays_on_original_origin(client, calls): BASE_URL + ENDPOINT, BASE_URL + "/public/v1/query/hop", ] - - -@pytest.mark.parametrize( - "location", - [ - "http://api.example.com" + ENDPOINT, - "https://api.example.com:8443" + ENDPOINT, - ], -) -def test_effective_origin_changes_are_not_followed(client, calls, location): - with pytest.raises(TurnkeyNetworkError): - send(client, calls, [FakeResponse(307, location)]) - - assert len(calls) == 1 - - -def test_redirect_limit_is_enforced(client, calls): - responses = [FakeResponse(307, BASE_URL + ENDPOINT) for _ in range(6)] - - with pytest.raises(TurnkeyNetworkError): - send(client, calls, responses) - - assert len(calls) == 5 diff --git a/pyproject.toml b/pyproject.toml index 7dbc563..6c42780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ dev = [ "pytest>=7.0.0", ] +[tool.ruff] +extend-exclude = ["*.md"] + [tool.mypy] python_version = "3.10" warn_return_any = false From 1e44a4c6e0bdbd32999016ff4ca53234b126d618 Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Sat, 15 Aug 2026 17:15:00 -0400 Subject: [PATCH 6/7] Improve redirect url validation --- codegen/http/generate_http.py | 43 +++++++++++-------- .../http/src/turnkey_http/generated/client.py | 43 +++++++++++-------- packages/http/tests/test_redirects.py | 31 +++++++++++-- 3 files changed, 77 insertions(+), 40 deletions(-) diff --git a/codegen/http/generate_http.py b/codegen/http/generate_http.py index f63fc8b..52be6be 100644 --- a/codegen/http/generate_http.py +++ b/codegen/http/generate_http.py @@ -92,6 +92,11 @@ def serialize_value(value): serialized = serialize_value(body) return json.dumps(serialized) + def _canonical_url(self, url: str) -> str: + prepared = requests.models.PreparedRequest() + prepared.prepare_url(url, None) + return str(prepared.url) + def _url_origin(self, url: str) -> str: parts = urlsplit(url) scheme = parts.scheme.lower() @@ -100,9 +105,10 @@ def _url_origin(self, url: str) -> str: return f"{scheme}://{host}:{port}" def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Response: - origin = self._url_origin(url) - current_url = url - for _ in range(MAX_REDIRECTS): + current_url = self._canonical_url(url) + origin = self._url_origin(current_url) + redirects = 0 + while True: response = requests.post( current_url, headers=headers, @@ -110,23 +116,24 @@ def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Respon timeout=self.default_timeout, allow_redirects=False ) - if response.status_code not in (307, 308): + if not 300 <= response.status_code < 400: return response + redirects += 1 + if redirects > MAX_REDIRECTS: + raise requests.TooManyRedirects(f"Exceeded {MAX_REDIRECTS} redirects") + location = response.headers.get("Location") - if location: - next_url = urljoin(current_url, location) - try: - if self._url_origin(next_url) == origin: - current_url = next_url - continue - except ValueError: - pass - raise requests.RequestException( - f"Not following redirect ({response.status_code}) to {location!r}" - ) - - raise requests.TooManyRedirects(f"Exceeded {MAX_REDIRECTS} redirects") + refusal = f"Not following redirect ({response.status_code}) to {location!r}" + if response.status_code not in (307, 308) or not location: + raise requests.RequestException(refusal) + try: + next_url = self._canonical_url(urljoin(current_url, location)) + except (requests.RequestException, ValueError) as exc: + raise requests.RequestException(refusal) from exc + if self._url_origin(next_url) != origin: + raise requests.RequestException(refusal) + current_url = next_url def _request(self, url: str, body: Dict[str, Any], response_type: type) -> Any: \"\"\"Make a request to the Turnkey API. @@ -575,7 +582,7 @@ def main(): output += "from ..version import VERSION\n\n" output += "T = TypeVar('T')\n\n" output += f"TERMINAL_ACTIVITY_STATUSES = {TERMINAL_ACTIVITY_STATUSES}\n\n" - output += "MAX_REDIRECTS = 5\n\n" + output += "MAX_REDIRECTS = 10\n\n" output += client_code # Ensure output directory exists diff --git a/packages/http/src/turnkey_http/generated/client.py b/packages/http/src/turnkey_http/generated/client.py index 203ba7c..c82df53 100644 --- a/packages/http/src/turnkey_http/generated/client.py +++ b/packages/http/src/turnkey_http/generated/client.py @@ -18,7 +18,7 @@ "ACTIVITY_STATUS_REJECTED", ] -MAX_REDIRECTS = 5 +MAX_REDIRECTS = 10 class TurnkeyClient: @@ -74,6 +74,11 @@ def serialize_value(value): serialized = serialize_value(body) return json.dumps(serialized) + def _canonical_url(self, url: str) -> str: + prepared = requests.models.PreparedRequest() + prepared.prepare_url(url, None) + return str(prepared.url) + def _url_origin(self, url: str) -> str: parts = urlsplit(url) scheme = parts.scheme.lower() @@ -86,9 +91,10 @@ def _url_origin(self, url: str) -> str: return f"{scheme}://{host}:{port}" def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Response: - origin = self._url_origin(url) - current_url = url - for _ in range(MAX_REDIRECTS): + current_url = self._canonical_url(url) + origin = self._url_origin(current_url) + redirects = 0 + while True: response = requests.post( current_url, headers=headers, @@ -96,23 +102,24 @@ def _post(self, url: str, headers: Dict[str, str], data: str) -> requests.Respon timeout=self.default_timeout, allow_redirects=False, ) - if response.status_code not in (307, 308): + if not 300 <= response.status_code < 400: return response - location = response.headers.get("Location") - if location: - next_url = urljoin(current_url, location) - try: - if self._url_origin(next_url) == origin: - current_url = next_url - continue - except ValueError: - pass - raise requests.RequestException( - f"Not following redirect ({response.status_code}) to {location!r}" - ) + redirects += 1 + if redirects > MAX_REDIRECTS: + raise requests.TooManyRedirects(f"Exceeded {MAX_REDIRECTS} redirects") - raise requests.TooManyRedirects(f"Exceeded {MAX_REDIRECTS} redirects") + location = response.headers.get("Location") + refusal = f"Not following redirect ({response.status_code}) to {location!r}" + if response.status_code not in (307, 308) or not location: + raise requests.RequestException(refusal) + try: + next_url = self._canonical_url(urljoin(current_url, location)) + except (requests.RequestException, ValueError) as exc: + raise requests.RequestException(refusal) from exc + if self._url_origin(next_url) != origin: + raise requests.RequestException(refusal) + current_url = next_url def _request(self, url: str, body: Dict[str, Any], response_type: type) -> Any: """Make a request to the Turnkey API. diff --git a/packages/http/tests/test_redirects.py b/packages/http/tests/test_redirects.py index 31ac2b2..9ea8d87 100644 --- a/packages/http/tests/test_redirects.py +++ b/packages/http/tests/test_redirects.py @@ -61,16 +61,20 @@ def post(url, headers, data, timeout, allow_redirects): [ (False, 307, "https://other.example.com" + ENDPOINT), (True, 308, "http://api.example.com" + ENDPOINT), + (False, 307, "https://evil.example\\@api.example.com/steal"), + (True, 308, "https://evil.example\\@api.example.com/steal"), + (False, 307, "https://[invalid" + ENDPOINT), + (False, 302, BASE_URL + ENDPOINT), + (False, 307, None), ], ) -def test_cross_origin_redirect_is_not_followed( - client, calls, signed, status_code, location -): +def test_disallowed_redirect_is_refused(client, calls, signed, status_code, location): responses = [FakeResponse(status_code, location)] - with pytest.raises(TurnkeyNetworkError): + with pytest.raises(TurnkeyNetworkError) as excinfo: send(client, calls, responses, signed) + assert f"({status_code}) to {location!r}" in str(excinfo.value.cause) assert len(calls) == 1 assert calls[0][3] is False @@ -99,3 +103,22 @@ def test_redirect_chain_stays_on_original_origin(client, calls): BASE_URL + ENDPOINT, BASE_URL + "/public/v1/query/hop", ] + + +@pytest.mark.parametrize("redirects,ok", [(10, True), (11, False)]) +def test_redirects_are_bounded_separately_from_initial_request( + client, calls, redirects, ok +): + responses = [ + FakeResponse(307, BASE_URL + f"/public/v1/query/hop{i}") + for i in range(redirects) + ] + responses.append(FakeResponse(200, payload={"result": "ok"})) + + if ok: + assert send(client, calls, responses) == {"result": "ok"} + assert len(calls) == redirects + 1 + else: + with pytest.raises(TurnkeyNetworkError): + send(client, calls, responses) + assert len(calls) == redirects From d6614b0fa8c7aaa2332cde5683b24791e2a5cfe3 Mon Sep 17 00:00:00 2001 From: Zeke Mostov Date: Sat, 15 Aug 2026 17:32:21 -0400 Subject: [PATCH 7/7] Trim redirect test coverage --- packages/http/tests/test_redirects.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/http/tests/test_redirects.py b/packages/http/tests/test_redirects.py index 9ea8d87..63a0794 100644 --- a/packages/http/tests/test_redirects.py +++ b/packages/http/tests/test_redirects.py @@ -62,7 +62,6 @@ def post(url, headers, data, timeout, allow_redirects): (False, 307, "https://other.example.com" + ENDPOINT), (True, 308, "http://api.example.com" + ENDPOINT), (False, 307, "https://evil.example\\@api.example.com/steal"), - (True, 308, "https://evil.example\\@api.example.com/steal"), (False, 307, "https://[invalid" + ENDPOINT), (False, 302, BASE_URL + ENDPOINT), (False, 307, None),