Skip to content
This repository was archived by the owner on Jul 10, 2026. It is now read-only.
Open
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
46 changes: 46 additions & 0 deletions tests/unit/test_httpclient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Unit tests for HTTP client lifecycle helpers. No network."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock

import pytest

from ttd_data.httpclient import close_clients


class _Owner:
client = object()
async_client = object()


def test_close_clients_clears_owner_references():
owner = _Owner()
sync = MagicMock()
async_client = MagicMock()
async_client.aclose = AsyncMock()

close_clients(owner, sync, sync_client_supplied=False, async_client=async_client, async_client_supplied=False)

assert owner.client is None
assert owner.async_client is None
sync.close.assert_called_once()


def test_close_clients_skips_supplied_sync_client():
owner = _Owner()
sync = MagicMock()

close_clients(owner, sync, sync_client_supplied=True, async_client=None, async_client_supplied=True)

sync.close.assert_not_called()


def test_close_clients_swallows_sync_close_errors():
owner = _Owner()
sync = MagicMock()
sync.close.side_effect = RuntimeError("already closed")

close_clients(owner, sync, sync_client_supplied=False, async_client=None, async_client_supplied=True)

assert owner.client is None
183 changes: 183 additions & 0 deletions tests/unit/test_resources_mocked.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Mocked HTTP resource-client tests for BaseDataClient. No network."""

from __future__ import annotations

import json
from typing import Any, Dict, List

from datetime import datetime, timezone

import httpx
import pytest

from ttd_data import models
from ttd_data.sdk import BaseDataClient
from ttd_data.uid2 import UserIdType


def _mock_transport(
expected_paths: List[str],
captured: Dict[str, Any],
) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response:
captured.setdefault("requests", []).append(request)
assert request.url.path in expected_paths
return httpx.Response(200, json={"FailedLines": []})

return httpx.MockTransport(handler)


def test_advertiser_ingest_posts_json_with_auth_header():
captured: Dict[str, Any] = {}
client = BaseDataClient(
client=httpx.Client(transport=_mock_transport(["/data/advertiser"], captured)),
server_url="https://api.test",
)

response = client.advertiser.ingest_advertiser_data(
ttd_auth="secret-token",
advertiser_id="adv-123",
items=[
models.AdvertiserDataItem(
tdid="df2df528-e032-4851-b7c6-99287c7d6bcd",
data=[models.AdvertiserData(name="loyalty_members")],
)
],
)

request = captured["requests"][0]
assert request.method == "POST"
assert request.headers["ttd-auth"] == "secret-token"
body = json.loads(request.content.decode())
assert body["AdvertiserId"] == "adv-123"
assert response.advertiser_data_server_response is not None


def test_third_party_ingest_hits_expected_path():
captured: Dict[str, Any] = {}
client = BaseDataClient(
client=httpx.Client(transport=_mock_transport(["/data/thirdparty"], captured)),
server_url="https://api.test",
)

client.third_party.ingest_third_party_data(
ttd_auth="secret-token",
data_provider_id="provider-9",
items=[
models.ThirdPartyDataItem(
tdid="df2df528-e032-4851-b7c6-99287c7d6bcd",
data=[models.ThirdPartyData(name="segment_a")],
)
],
)

request = captured["requests"][0]
assert request.url.path == "/data/thirdparty"
body = json.loads(request.content.decode())
assert body["DataProviderId"] == "provider-9"


def test_offline_conversion_ingest_hits_expected_path():
captured: Dict[str, Any] = {}
client = BaseDataClient(
client=httpx.Client(
transport=_mock_transport(["/providerapi/offlineconversion"], captured)
),
server_url="https://api.test",
)

client.offline_conversion.ingest_offline_conversion_data(
ttd_auth="secret-token",
data_provider_id="provider-9",
items=[
models.OfflineConversionDataItem(
tracking_tag_id="tag-1",
timestamp_utc=datetime(2025, 1, 1, tzinfo=timezone.utc),
user_id_array=[
[UserIdType.TDID, "df2df528-e032-4851-b7c6-99287c7d6bcd"],
],
)
],
)

request = captured["requests"][0]
assert request.url.path == "/providerapi/offlineconversion"


def test_deletion_opt_out_advertiser_hits_expected_path():
captured: Dict[str, Any] = {}
client = BaseDataClient(
client=httpx.Client(
transport=_mock_transport(["/data/deletion-optout/advertiser"], captured)
),
server_url="https://api.test",
)

client.deletion_opt_out.data_subject_request_advertiser_data(
ttd_auth="secret-token",
advertiser_id="adv-123",
items=[
models.PartnerDsrDataItem(
tdid="df2df528-e032-4851-b7c6-99287c7d6bcd",
request_type=models.PartnerDsrRequestType.DELETION,
)
],
)

request = captured["requests"][0]
assert request.method == "POST"
assert request.url.path == "/data/deletion-optout/advertiser"


def test_custom_http_headers_are_forwarded():
captured: Dict[str, Any] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(200, json={"FailedLines": []})

client = BaseDataClient(
client=httpx.Client(transport=httpx.MockTransport(handler)),
server_url="https://api.test",
)

client.advertiser.ingest_advertiser_data(
ttd_auth="secret-token",
advertiser_id="adv-123",
http_headers={"X-Trace": "abc"},
items=[
models.AdvertiserDataItem(
tdid="df2df528-e032-4851-b7c6-99287c7d6bcd",
data=[models.AdvertiserData(name="loyalty_members")],
)
],
)

assert captured["request"].headers["x-trace"] == "abc"


def test_server_url_override_is_used():
captured: Dict[str, Any] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={"FailedLines": []})

client = BaseDataClient(
client=httpx.Client(transport=httpx.MockTransport(handler)),
server_url="https://default.test",
)

client.advertiser.ingest_advertiser_data(
ttd_auth="secret-token",
advertiser_id="adv-123",
server_url="https://override.test",
items=[
models.AdvertiserDataItem(
tdid="df2df528-e032-4851-b7c6-99287c7d6bcd",
data=[models.AdvertiserData(name="loyalty_members")],
)
],
)

assert captured["url"].startswith("https://override.test/data/advertiser")
151 changes: 151 additions & 0 deletions tests/unit/test_retries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Unit tests for HTTP retry utilities (`ttd_data.utils.retries`). No network."""

from __future__ import annotations

from unittest.mock import MagicMock

import httpx
import pytest

from ttd_data.utils.retries import (
BackoffStrategy,
PermanentError,
Retries,
RetryConfig,
TemporaryError,
_parse_retry_after_header,
_parse_retry_after_ms_header,
_get_sleep_interval,
retry,
retry_with_backoff,
)


def test_backoff_strategy_rejects_negative_jitter():
with pytest.raises(ValueError, match="jitter_ms must be >= 0"):
BackoffStrategy(500, 60000, 1.5, 3600000, jitter_ms=-1)


def test_backoff_strategy_accepts_zero_jitter():
strategy = BackoffStrategy(500, 60000, 1.5, 3600000, jitter_ms=0)
assert strategy.jitter_ms == 0


def test_parse_retry_after_header_seconds():
response = httpx.Response(503, headers={"retry-after": "2.5"})
assert _parse_retry_after_header(response) == 2500


def test_parse_retry_after_header_missing():
response = httpx.Response(503)
assert _parse_retry_after_header(response) is None


def test_parse_retry_after_header_invalid():
response = httpx.Response(503, headers={"retry-after": "not-a-date-or-number"})
assert _parse_retry_after_header(response) is None


def test_parse_retry_after_ms_header():
response = httpx.Response(503, headers={"retry-after-ms": "1500"})
assert _parse_retry_after_ms_header(response) == 1500


def test_parse_retry_after_ms_header_negative_ignored():
response = httpx.Response(503, headers={"retry-after-ms": "-1"})
assert _parse_retry_after_ms_header(response) is None


def test_temporary_error_carries_retry_after():
response = httpx.Response(503, headers={"retry-after": "1"})
err = TemporaryError(response)
assert err.retry_after == 1000


def test_get_sleep_interval_uses_retry_after_on_temporary_error():
response = httpx.Response(503, headers={"retry-after": "3"})
err = TemporaryError(response)
assert _get_sleep_interval(err, 500, 60000, 1.5, 0) == 3.0


def test_get_sleep_interval_exponential_backoff_capped(monkeypatch):
monkeypatch.setattr("ttd_data.utils.retries.random.uniform", lambda _a, _b: 0)
sleep = _get_sleep_interval(RuntimeError("boom"), 1000, 2000, 2.0, 3)
assert sleep == 2.0


def test_retries_uses_status_codes_override():
config = RetryConfig(
"backoff",
BackoffStrategy(1, 50, 1.1, 100),
retry_connection_errors=False,
status_codes_override=["418"],
)
retries = Retries(config, ["500"])
assert retries.status_codes == ["418"]


def test_retry_none_strategy_calls_func_once():
config = RetryConfig("none", BackoffStrategy(1, 50, 1.1, 100), False)
retries = Retries(config, ["503"])
calls = {"n": 0}

def func():
calls["n"] += 1
return httpx.Response(200)

result = retry(func, retries)
assert result.status_code == 200
assert calls["n"] == 1


def test_retry_raises_temporary_error_for_matching_status(monkeypatch):
monkeypatch.setattr("ttd_data.utils.retries.time.sleep", lambda _s: None)
config = RetryConfig("backoff", BackoffStrategy(1, 10, 1.0, 50), False)
retries = Retries(config, ["503"])
attempts = {"n": 0}

def func():
attempts["n"] += 1
if attempts["n"] == 1:
return httpx.Response(503)
return httpx.Response(200)

result = retry(func, retries)
assert result.status_code == 200
assert attempts["n"] == 2


def test_retry_raises_permanent_error_on_connection_failure():
config = RetryConfig("backoff", BackoffStrategy(1, 10, 1.0, 50), False)
retries = Retries(config, ["503"])

def func():
raise httpx.ConnectError("connection refused")

with pytest.raises(httpx.ConnectError, match="connection refused"):
retry(func, retries)


def test_retry_with_backoff_reraises_permanent_error_inner():
def func():
raise PermanentError(ValueError("nope"))

with pytest.raises(ValueError, match="nope"):
retry_with_backoff(func, max_elapsed_time=100)


def test_retry_matches_5xx_wildcard_status_code(monkeypatch):
monkeypatch.setattr("ttd_data.utils.retries.time.sleep", lambda _s: None)
config = RetryConfig("backoff", BackoffStrategy(1, 10, 1.0, 50), False)
retries = Retries(config, ["5XX"])
attempts = {"n": 0}

def func():
attempts["n"] += 1
if attempts["n"] == 1:
return httpx.Response(502)
return httpx.Response(200)

result = retry(func, retries)
assert result.status_code == 200
Loading