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
33 changes: 27 additions & 6 deletions src/layerv_qurl/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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_\-]+$")

Expand Down Expand Up @@ -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]:
Expand Down
86 changes: 83 additions & 3 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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 ---


Expand Down