Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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.
19 changes: 14 additions & 5 deletions src/tidelight/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
46 changes: 46 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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 = []

Expand Down
Loading