diff --git a/src/layerv_qurl/_utils.py b/src/layerv_qurl/_utils.py index 9e4019e..8f87fa0 100644 --- a/src/layerv_qurl/_utils.py +++ b/src/layerv_qurl/_utils.py @@ -15,13 +15,15 @@ import logging import random import re +import secrets +import time +import uuid from collections.abc import Iterable, Mapping, Set from datetime import datetime from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from typing import TYPE_CHECKING, Any, TypeVar from urllib.parse import quote -from uuid import uuid4 from layerv_qurl.errors import ( AuthenticationError, @@ -105,10 +107,11 @@ # POST requests still only retry rate limits: resolve can consume one-time # tokens after an NHP knock failure, and service errors are not cached. RETRYABLE_STATUS_POST = {429} -# DELETE keeps the wider HTTP retry set without an idempotency key because -# repeated deletes are safe by HTTP semantics; create/update mutations need -# service-side replay protection. -IDEMPOTENCY_METHODS = {"POST", "PATCH"} +# POST/PATCH, plus any future PUT endpoints from the API contract, carry a +# qurl-service replay key so retried writes are tied to one logical operation. +# DELETE keeps the wider HTTP retry set without a key because repeated deletes +# are safe by HTTP semantics. +IDEMPOTENCY_METHODS = {"POST", "PUT", "PATCH"} _RESOURCE_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]+$") @@ -253,7 +256,25 @@ def ensure_mutation_idempotency(method: str, headers: dict[str, str]) -> None: return if any(key.lower() == "idempotency-key" for key in headers): return - headers["Idempotency-Key"] = str(uuid4()) + headers["Idempotency-Key"] = _uuid7() + + +def _uuid7() -> str: + """Generate a UUIDv7 string for Python versions before stdlib uuid7. + + The random fields are for uniqueness, not monotonic ordering. + """ + timestamp_ms = time.time_ns() // 1_000_000 + rand_a = secrets.randbits(12) + rand_b = secrets.randbits(62) + value = ( + (timestamp_ms << 80) + | (0x7 << 76) + | (rand_a << 64) + | (0b10 << 62) + | rand_b + ) + return str(uuid.UUID(int=value)) def _meta_page(meta: dict[str, Any] | None) -> tuple[str | None, bool]: diff --git a/tests/test_client.py b/tests/test_client.py index a17275f..b7e04ea 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,17 +4,18 @@ import json import logging +import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, Any from unittest.mock import patch +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + import httpx import pytest import respx -if TYPE_CHECKING: - from collections.abc import AsyncGenerator - from layerv_qurl import ( AsyncQURLClient, QURLClient, @@ -1105,6 +1106,36 @@ def test_post_still_retries_on_429(retry_client: QURLClient) -> None: assert len(first_key) == 36 +@respx.mock +def test_post_network_retry_reuses_auto_idempotency_key( + retry_client: QURLClient, +) -> None: + route = respx.post(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.ConnectError("connection reset"), + httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_abc123def45", + "qurl_link": "https://qurl.link/#at_test", + "qurl_site": "https://r_abc123def45.qurl.site", + "qurl_id": "q_abc", + }, + }, + ), + ] + + with patch("layerv_qurl.client.time.sleep"): + result = retry_client.create(target_url="https://example.com", expires_in="24h") + + assert result.resource_id == "r_abc123def45" + assert route.call_count == 2 + first_key = route.calls[0].request.headers["idempotency-key"] + assert first_key == route.calls[1].request.headers["idempotency-key"] + assert len(first_key) == 36 + + @respx.mock def test_auto_idempotency_applies_to_supported_mutations(client: QURLClient) -> None: quota_route = respx.get(f"{BASE_URL}/v1/quota").mock( @@ -1135,6 +1166,19 @@ def test_auto_idempotency_applies_to_supported_mutations(client: QURLClient) -> assert "idempotency-key" not in webhook_route.calls[0].request.headers +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) +def test_ensure_mutation_idempotency_generates_uuid7_for_supported_methods( + method: str, +) -> None: + headers: dict[str, str] = {} + + ensure_mutation_idempotency(method, headers) + + parsed = uuid.UUID(headers["Idempotency-Key"]) + assert parsed.version == 7 + assert parsed.variant == uuid.RFC_4122 + + def test_ensure_mutation_idempotency_preserves_explicit_header() -> None: headers = {"idempotency-key": "caller-provided-key"} @@ -1235,6 +1279,42 @@ async def test_async_patch_retry_reuses_auto_idempotency_key() -> None: await client.close() +@respx.mock +@pytest.mark.asyncio +async def test_async_post_network_retry_reuses_auto_idempotency_key() -> None: + client = AsyncQURLClient(api_key="lv_live_test", base_url=BASE_URL, max_retries=2) + try: + route = respx.post(f"{BASE_URL}/v1/qurls") + route.side_effect = [ + httpx.ConnectError("connection reset"), + httpx.Response( + 201, + json={ + "data": { + "resource_id": "r_async", + "qurl_link": "https://qurl.link/#at_async", + "qurl_site": "https://r_async.qurl.site", + "qurl_id": "q_async", + }, + }, + ), + ] + + with patch("layerv_qurl.async_client.asyncio.sleep"): + result = await client.create( + target_url="https://example.com", + expires_in="24h", + ) + + assert result.resource_id == "r_async" + assert route.call_count == 2 + first_key = route.calls[0].request.headers["idempotency-key"] + assert first_key == route.calls[1].request.headers["idempotency-key"] + assert len(first_key) == 36 + finally: + await client.close() + + # --- Non-JSON error ---