From ef4191d2a6331c44c5c9fc712706dcd057976c4e Mon Sep 17 00:00:00 2001 From: Tidelight Mirror Bot Date: Fri, 16 Oct 2026 13:05:35 +0000 Subject: [PATCH] chore(sync): mirror python from f1417b938506f7f20202273c4e5b9161fac7363f --- README.md | 4 ++-- src/tidelight/client.py | 19 ++++++++++++----- tests/test_client.py | 46 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d9676d3..448ce64 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tidelight Python SDK -Unpublished client for the implemented Tidelight Intelligence Cloud v1 market, asynchronous company snapshot, and research webhook endpoints. Research calls require `research:run`; webhook calls require `alerts:manage`. The package will be released after package ownership and release integration are configured. +Unpublished client for the implemented Tidelight Intelligence Cloud v1 market, asynchronous company snapshot, and research webhook endpoints. Market calls require `market:read`; research calls require `research:run`; webhook calls require `alerts:manage`. Market responses can be denied with `provider_terms_unconfirmed` until redistribution terms are confirmed. The client surfaces that gate without retrying it. The package will be released after package ownership and release integration are configured. Install locally with `pip install ./sdk/python`. Keep the API key on your server. @@ -34,4 +34,4 @@ deliveries = client.list_webhook_deliveries() Webhook mutations are not retried automatically because the API does not yet accept webhook idempotency keys; this avoids duplicate endpoint creation or secret rotation after an ambiguous timeout. Webhook destination reachability and DNS safety are checked by the API. A client-side HTTPS check is not an authorization to deliver to a destination. -The client retries network errors, 429, and transient 5xx statuses at most twice by default. It enforces a 10 second request timeout and raises `ApiError` with status, code, and request ID. +The client retries network errors, 429, and transient 5xx statuses at most twice by default, except `provider_terms_unconfirmed`, which is returned immediately. It enforces a 10 second request timeout and raises `ApiError` with status, code, and request ID. The default transport does not follow redirects, and remote API URLs are restricted to `api.tidelight.app` so bearer credentials remain scoped to Tidelight. diff --git a/src/tidelight/client.py b/src/tidelight/client.py index 798da46..b6ab29d 100644 --- a/src/tidelight/client.py +++ b/src/tidelight/client.py @@ -9,7 +9,14 @@ from typing import Any, Callable, Iterator from urllib.error import HTTPError, URLError from urllib.parse import urlencode, urlparse -from urllib.request import Request, urlopen +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +class _NoRedirect(HTTPRedirectHandler): + """Keep bearer credentials on the configured API origin.""" + + def redirect_request(self, request, response, code, message, headers, new_url): + return None @dataclass(frozen=True) @@ -59,12 +66,14 @@ def __init__(self, status: int, code: str, message: str, request_id: str | None class Client: - def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v1", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] = urlopen, sleeper: Callable[[float], None] = time.sleep): + def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v1", timeout: float = 10, max_retries: int = 2, opener: Callable[..., Any] | None = None, sleeper: Callable[[float], None] = time.sleep): if not re.fullmatch(r"tl_(test|live)_[A-Za-z0-9_-]{43}", api_key): raise ValueError("A valid Tidelight API key is required.") parsed = urlparse(base_url) if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1"}): raise ValueError("The API URL must use HTTPS.") + if parsed.scheme == "https" and (parsed.hostname != "api.tidelight.app" or parsed.port not in (None, 443)): + raise ValueError("The API URL must use api.tidelight.app over HTTPS.") if parsed.username or parsed.password or parsed.query or parsed.fragment: raise ValueError("The API URL cannot contain credentials, query, or fragment.") if not 0.1 <= timeout <= 120: @@ -75,7 +84,7 @@ def __init__(self, api_key: str, *, base_url: str = "https://api.tidelight.app/v self._base_url = base_url.rstrip("/") self._timeout = timeout self._max_retries = max_retries - self._opener = opener + self._opener = opener or build_opener(_NoRedirect()).open self._sleeper = sleeper def list_tokenized_equities(self, *, limit: int = 20, cursor: str | None = None, underlying_ticker: str | None = None) -> InstrumentPage: @@ -109,7 +118,7 @@ def list_tokenized_equities(self, *, limit: int = 20, cursor: str | None = None, details = json.load(error).get("error", {}) except (ValueError, AttributeError): details = {} - if error.code in {429, 502, 503, 504} and attempt < self._max_retries: + if error.code in {429, 502, 503, 504} and details.get("code") != "provider_terms_unconfirmed" and attempt < self._max_retries: self._pause(attempt, error.headers.get("Retry-After")) continue raise ApiError(error.code, details.get("code", "api_error"), details.get("message", "Tidelight API request failed."), details.get("request_id") or error.headers.get("X-Request-Id")) from None @@ -238,7 +247,7 @@ def _job_request(self, path: str, method: str, body: dict[str, Any] | None = Non details = json.load(error).get("error", {}) except (ValueError, AttributeError): details = {} - if error.code in {429, 502, 503, 504} and attempt < retry_limit: + if error.code in {429, 502, 503, 504} and details.get("code") != "provider_terms_unconfirmed" and attempt < retry_limit: self._pause(attempt, error.headers.get("Retry-After")); continue raise ApiError(error.code, details.get("code", "api_error"), details.get("message", "Tidelight API request failed."), details.get("request_id") or error.headers.get("X-Request-Id")) from None except (URLError, TimeoutError, OSError): diff --git a/tests/test_client.py b/tests/test_client.py index db78d88..1a3ba2d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,8 @@ import io import json +import threading import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.error import HTTPError from urllib.parse import parse_qs, urlparse @@ -84,6 +86,10 @@ def denied(request, timeout): def test_input_validation(self): with self.assertRaises(ValueError): Client(KEY, base_url="http://example.com/api/v1") + with self.assertRaises(ValueError): + Client(KEY, base_url="https://attacker.example/v1") + with self.assertRaises(ValueError): + Client(KEY, base_url="https://api.tidelight.app:8443/v1") client = Client(KEY, opener=lambda *_args, **_kwargs: self.fail("transport called")) with self.assertRaises(ValueError): client.list_tokenized_equities(limit=101) @@ -92,6 +98,46 @@ def test_input_validation(self): with self.assertRaises(ValueError): client.list_tokenized_equities(underlying_ticker="AAPL?") + def test_default_transport_does_not_follow_redirects(self): + seen = [] + + class RedirectHandler(BaseHTTPRequestHandler): + def do_GET(self): + seen.append(self.path) + self.send_response(302) + self.send_header("Location", "https://attacker.example/collect") + self.end_headers() + + def log_message(self, *_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), RedirectHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = Client(KEY, base_url=f"http://127.0.0.1:{server.server_port}/v1", max_retries=0) + with self.assertRaises(ApiError) as caught: + client.list_tokenized_equities() + self.assertEqual(caught.exception.status, 302) + self.assertEqual(seen, ["/v1/market/instruments?kind=rtoken&limit=20"]) + finally: + server.shutdown() + thread.join(timeout=2) + server.server_close() + + def test_provider_terms_gate_is_not_retried(self): + calls = [] + + def denied(request, timeout): + calls.append(request.full_url) + raise HTTPError(request.full_url, 503, "Unavailable", {}, io.BytesIO(b'{"error":{"code":"provider_terms_unconfirmed","message":"Market redistribution terms are not confirmed."}}')) + + client = Client(KEY, opener=denied, max_retries=3, sleeper=lambda _: None) + with self.assertRaises(ApiError) as caught: + client.list_tokenized_equities() + self.assertEqual(caught.exception.code, "provider_terms_unconfirmed") + self.assertEqual(len(calls), 1) + def test_underlying_ticker_is_normalized_and_sent_before_pagination(self): calls = []