diff --git a/CHANGELOG.md b/CHANGELOG.md index 4993e77..ea5841a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Notable changes to the DIDWW Verification SDK for Python. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] — 2026-10 + +### Added + +- **`code_length` on `SmsInfo` and `CalloutInfo`.** The generated code's length, 4–8. + +- **`DidwwRateLimitedError`**, raised on 429 when a start is too soon after a + non-denied one for the same destination (`destination_in_cooldown`). Carries + `retry_after`, the wait in whole seconds when the response has a `Retry-After` + header. Not auto-retried; wait `retry_after` and start again yourself. + +### Changed + +- `expires_at` and `sms.interception_timeout` reflect the application's configured + code lifetime (60–600 seconds, default 300) rather than a fixed window. + +- A 429 now raises `DidwwRateLimitedError`, a subclass of `DidwwApiError`, rather + than the base class. `except DidwwApiError` still catches it; a strict + `type(e) is DidwwApiError` check no longer matches. + ## [1.0.0] — 2026-09 First release. diff --git a/README.md b/README.md index f238274..1e9b76c 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,13 @@ verification.callout.language # the tag the announcement is played in The two catalogues are separate: a tag with an SMS template may still have no recording. +Each response also reports the generated code's length, 4–8: + +```python +verification.sms.code_length +verification.callout.code_length +``` + ## Environments ```python @@ -286,6 +293,7 @@ except DidwwApiError as exc: | `DidwwBalanceInsufficientError` | 402 | | `DidwwNotFoundError` | 404 | | `DidwwValidationError` | 400, 422 | +| `DidwwRateLimitedError` | 429 | | `DidwwServerError` | 5xx | | `DidwwApiError` | any other non-2xx; base class of the above | | `DidwwTransportError` | no response: connect, timeout, TLS | @@ -318,6 +326,11 @@ and a repeated report consumes one of three attempts. Exceeding that limit is an with a normal 200 whose status is `failed` — read the result rather than counting attempts yourself. +A start too soon after a non-denied one for the same destination is refused with 429 +and `destination_in_cooldown`, as `DidwwRateLimitedError`. The SDK never retries it +automatically: wait `retry_after` seconds — `None` when the response carried no +`Retry-After` header — then start again yourself. + ## Logging The SDK itself logs nothing. Its HTTP client, httpx2, logs every request's method and diff --git a/contract/wire_contract.json b/contract/wire_contract.json index c3d1fb2..4813174 100644 --- a/contract/wire_contract.json +++ b/contract/wire_contract.json @@ -1,5 +1,5 @@ { - "capturedAt": "2026-09-07", + "capturedAt": "2026-09-28", "source": "Captured from the verification service's own error registry and route table.", "baseUrls": { "production": "https://verification.didww.com", @@ -35,7 +35,8 @@ "unauthorized", "balance_insufficient", "validation_failed", - "internal_error" + "internal_error", + "destination_in_cooldown" ], "verificationErrorCodes": [ "dispatch_failed", @@ -61,6 +62,7 @@ 401, 402, 422, + 429, 500 ] }, @@ -170,7 +172,11 @@ }, "constraints": { "destinationNormalization": "every non-digit is stripped, the leading '+' included", - "generatedCodeLength": 6, + "generatedCodeLength": { + "min": 4, + "max": 8, + "default": 6 + }, "codePlaceholder": "{{CODE}}", "languageFallback": "en-US", "reportAttemptsMax": 3 diff --git a/examples/polling.py b/examples/polling.py index 2117f19..4e4a03d 100644 --- a/examples/polling.py +++ b/examples/polling.py @@ -9,23 +9,30 @@ import os import sys import time +from datetime import datetime, timezone from didww_verification import BasicAuth, Environment, VerificationClient def main(verification_id: str) -> int: auth = BasicAuth(os.environ["DIDWW_KEY"], os.environ["DIDWW_SECRET"]) - deadline = time.monotonic() + 120 # a verification's own lifetime with VerificationClient(auth, environment=Environment.SANDBOX) as client: - while time.monotonic() < deadline: - verification = client.get_verification(verification_id) + verification = client.get_verification(verification_id) + # The application's own configured code lifetime (60-600s, default 300), + # not a fixed window -- read it from the verification rather than hard-coding it. + deadline = verification.expires_at or datetime.now(timezone.utc) + + while True: # Anything but "pending" is terminal. Listing the terminal ones instead # would poll a status added after this release forever. if verification.is_finished: print(f"{verification.status}: {verification.error_detail or 'ok'}") return 0 if verification.status == "verified" else 1 + if datetime.now(timezone.utc) >= deadline: + break time.sleep(2) + verification = client.get_verification(verification_id) print("gave up waiting") return 1 diff --git a/src/didww_verification/__init__.py b/src/didww_verification/__init__.py index 44e1e2a..0ca5ebe 100644 --- a/src/didww_verification/__init__.py +++ b/src/didww_verification/__init__.py @@ -28,6 +28,7 @@ DidwwConfigurationError, DidwwDecodingError, DidwwNotFoundError, + DidwwRateLimitedError, DidwwServerError, DidwwTransportError, DidwwUnauthorizedError, @@ -73,6 +74,7 @@ "DidwwConfigurationError", "DidwwDecodingError", "DidwwNotFoundError", + "DidwwRateLimitedError", "DidwwServerError", "DidwwTransportError", "DidwwUnauthorizedError", diff --git a/src/didww_verification/_responses.py b/src/didww_verification/_responses.py index b3ea7d9..80a0f58 100644 --- a/src/didww_verification/_responses.py +++ b/src/didww_verification/_responses.py @@ -3,7 +3,9 @@ from __future__ import annotations import json -from dataclasses import dataclass +import re +from collections.abc import Mapping +from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal, InvalidOperation from typing import TypeAlias, cast @@ -14,6 +16,7 @@ DidwwBalanceInsufficientError, DidwwDecodingError, DidwwNotFoundError, + DidwwRateLimitedError, DidwwServerError, DidwwTransportError, DidwwUnauthorizedError, @@ -42,6 +45,7 @@ class HttpOutcome: status: int body: bytes + headers: Mapping[str, str] = field(default_factory=dict[str, str]) @dataclass(frozen=True, slots=True) @@ -72,11 +76,26 @@ def _error_class(status: int) -> type[DidwwApiError]: return DidwwNotFoundError if status in (400, 422): return DidwwValidationError + if status == 429: + return DidwwRateLimitedError if 500 <= status <= 599: return DidwwServerError return DidwwApiError +def _retry_after(headers: Mapping[str, str]) -> int | None: + """``Retry-After`` as whole seconds, or ``None`` when absent or not plain digits. + + The API always sends the delta-seconds form, never an HTTP-date. ``int()`` alone + would also accept a sign, underscores or a decimal point, none of which this + header legitimately carries. + """ + value = headers.get("retry-after") + if value is None or not re.fullmatch(r"\d+", value.strip()): + return None + return int(value) + + def _parse_errors(body: bytes) -> tuple[ErrorItem, ...]: """Read the ``{"errors": [...]}`` envelope, tolerating anything that is not one. @@ -122,6 +141,12 @@ def _required_str(value: object, field: str) -> str: raise DidwwDecodingError(f"{field} is missing or not a string: {value!r}") +def _required_int(value: object, field: str) -> int: + if isinstance(value, int) and not isinstance(value, bool): + return value + raise DidwwDecodingError(f"{field} is missing or not an integer: {value!r}") + + def _sms(block: object) -> SmsInfo | None: if block is None: return None @@ -136,6 +161,7 @@ def _sms(block: object) -> SmsInfo | None: language=_optional_str(fields.get("language"), "sms.language"), interception_timeout=timeout, app_hash=_optional_str(fields.get("app_hash"), "sms.app_hash"), + code_length=_required_int(fields.get("code_length"), "sms.code_length"), ) @@ -145,7 +171,10 @@ def _callout(block: object) -> CalloutInfo | None: if not isinstance(block, dict): raise DidwwDecodingError(f"callout is not an object: {block!r}") fields = cast("dict[str, object]", block) - return CalloutInfo(language=_optional_str(fields.get("language"), "callout.language")) + return CalloutInfo( + language=_optional_str(fields.get("language"), "callout.language"), + code_length=_required_int(fields.get("code_length"), "callout.code_length"), + ) def _optional_decimal(value: object, field: str) -> Decimal | None: @@ -172,9 +201,17 @@ def _checked_body(outcome: Outcome) -> bytes: raise DidwwTransportError(str(outcome.cause)) from outcome.cause if not 200 <= outcome.status < 300: - raise _error_class(outcome.status)( - status=outcome.status, errors=_parse_errors(outcome.body), body=_snippet(outcome.body) - ) + cls = _error_class(outcome.status) + errors = _parse_errors(outcome.body) + body = _snippet(outcome.body) + if cls is DidwwRateLimitedError: + raise DidwwRateLimitedError( + status=outcome.status, + errors=errors, + body=body, + retry_after=_retry_after(outcome.headers), + ) + raise cls(status=outcome.status, errors=errors, body=body) return outcome.body diff --git a/src/didww_verification/_version.py b/src/didww_verification/_version.py index 3277f64..2a3eb2f 100644 --- a/src/didww_verification/_version.py +++ b/src/didww_verification/_version.py @@ -1 +1 @@ -VERSION = "1.0.0" +VERSION = "1.1.0" diff --git a/src/didww_verification/async_client.py b/src/didww_verification/async_client.py index 76647d9..e5dde95 100644 --- a/src/didww_verification/async_client.py +++ b/src/didww_verification/async_client.py @@ -182,7 +182,7 @@ async def _execute(self, spec: rq.RequestSpec, decode: Callable[[Outcome], T]) - except httpx2.HTTPError as exc: outcome = TransportFailure(exc) else: - outcome = HttpOutcome(response.status_code, response.content) + outcome = HttpOutcome(response.status_code, response.content, response.headers) if attempt < attempts and is_retryable(outcome): await anyio.sleep(delay_for(attempt, self._retry.base_delay, self._retry.rand())) continue diff --git a/src/didww_verification/client.py b/src/didww_verification/client.py index b097655..115f982 100644 --- a/src/didww_verification/client.py +++ b/src/didww_verification/client.py @@ -180,7 +180,7 @@ def _execute(self, spec: rq.RequestSpec, decode: Callable[[Outcome], T]) -> T: except httpx2.HTTPError as exc: outcome = TransportFailure(exc) else: - outcome = HttpOutcome(response.status_code, response.content) + outcome = HttpOutcome(response.status_code, response.content, response.headers) if attempt < attempts and is_retryable(outcome): time.sleep(delay_for(attempt, self._retry.base_delay, self._retry.rand())) continue diff --git a/src/didww_verification/errors.py b/src/didww_verification/errors.py index e68c8ec..bb09290 100644 --- a/src/didww_verification/errors.py +++ b/src/didww_verification/errors.py @@ -15,6 +15,7 @@ "DidwwConfigurationError", "DidwwDecodingError", "DidwwNotFoundError", + "DidwwRateLimitedError", "DidwwServerError", "DidwwTransportError", "DidwwUnauthorizedError", @@ -115,5 +116,26 @@ class DidwwValidationError(DidwwApiError): """400 or 422.""" +class DidwwRateLimitedError(DidwwApiError): + """429: a start too soon after a non-denied one for the same destination. + + Never retried automatically. ``retry_after`` is the wait in whole seconds from + ``Retry-After``, or ``None`` when the header was absent or unparsable -- wait + that long, then start again yourself. + """ + + def __init__( + self, + message: str | None = None, + *, + status: int, + errors: tuple[ErrorItem, ...] = (), + body: str | None = None, + retry_after: int | None = None, + ) -> None: + super().__init__(message, status=status, errors=errors, body=body) + self.retry_after = retry_after + + class DidwwServerError(DidwwApiError): """5xx, including an error produced by infrastructure in front of the API.""" diff --git a/src/didww_verification/models.py b/src/didww_verification/models.py index 76a540c..25f7354 100644 --- a/src/didww_verification/models.py +++ b/src/didww_verification/models.py @@ -45,9 +45,13 @@ class SmsInfo: template: str | None language: str | None interception_timeout: int | None + """Seconds the SMS Retriever stays armed for. Equal to the application's + configured code lifetime (60-600, default 300), not a fixed window.""" app_hash: str | None """Echoed back only when a hash was stored. Equality with what you sent is the only confirmation it was accepted.""" + code_length: int + """The generated code's length, 4-8.""" @dataclass(frozen=True, slots=True) @@ -55,6 +59,8 @@ class CalloutInfo: """The ``callout`` block, present only when the delivery method is ``callout``.""" language: str | None + code_length: int + """The generated code's length, 4-8.""" @dataclass(frozen=True, slots=True) @@ -73,6 +79,8 @@ class Verification: error_code: VerificationErrorCode | None error_detail: str | None expires_at: datetime | None + """When the code stops being acceptable: creation time plus the application's + configured code lifetime (60-600s, default 300), not a fixed window.""" sms: SmsInfo | None callout: CalloutInfo | None raw: Mapping[str, Any] | None = field(default=None, compare=False, hash=False, repr=False) diff --git a/src/didww_verification/vocabulary.py b/src/didww_verification/vocabulary.py index 2d8a437..5b7d1c7 100644 --- a/src/didww_verification/vocabulary.py +++ b/src/didww_verification/vocabulary.py @@ -77,6 +77,7 @@ "balance_insufficient", "validation_failed", "internal_error", + "destination_in_cooldown", *VERIFICATION_ERROR_CODES, ) @@ -112,6 +113,7 @@ "balance_insufficient", "validation_failed", "internal_error", + "destination_in_cooldown", "dispatch_failed", "expired", "too_many_attempts", diff --git a/tests/conftest.py b/tests/conftest.py index 8ad037c..d5e873d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,7 +32,8 @@ def verification_payload(**overrides: Any) -> dict[str, Any]: "sms": { "template": "Your code is {{CODE}}", "language": "en-US", - "interception_timeout": 120, + "interception_timeout": 300, + "code_length": 6, }, } data.update(overrides) diff --git a/tests/test_responses.py b/tests/test_responses.py index 7ca2068..a29bfb2 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -12,6 +12,7 @@ DidwwBalanceInsufficientError, DidwwDecodingError, DidwwNotFoundError, + DidwwRateLimitedError, DidwwServerError, DidwwTransportError, DidwwUnauthorizedError, @@ -72,12 +73,34 @@ def test_a_verification_is_hashable(self) -> None: assert len({v, decode_verification(ok())}) == 1 def test_the_sms_block_is_absent_for_a_callout(self) -> None: - payload = verification_payload(delivery_method="callout", callout={"language": "de-DE"}) + payload = verification_payload( + delivery_method="callout", callout={"language": "de-DE", "code_length": 6} + ) del payload["sms"] v = decode_verification(HttpOutcome(200, json.dumps({"data": payload}).encode())) assert v.sms is None assert v.callout is not None and v.callout.language == "de-DE" + def test_sms_code_length_is_readable_when_present(self) -> None: + v = decode_verification(ok()) + assert v.sms is not None + assert v.sms.code_length == 6 + + def test_sms_code_length_rejects_a_bool(self) -> None: + """``bool`` is an ``int`` subclass in Python, so it needs an explicit check.""" + sms = {**verification_payload()["sms"], "code_length": True} + with pytest.raises(DidwwDecodingError): + decode_verification(ok(sms=sms)) + + def test_callout_code_length_is_readable_when_present(self) -> None: + payload = verification_payload( + delivery_method="callout", callout={"language": "de-DE", "code_length": 4} + ) + del payload["sms"] + v = decode_verification(HttpOutcome(200, json.dumps({"data": payload}).encode())) + assert v.callout is not None + assert v.callout.code_length == 4 + class TestErrors: @pytest.mark.parametrize( @@ -88,6 +111,7 @@ class TestErrors: (402, DidwwBalanceInsufficientError), (404, DidwwNotFoundError), (422, DidwwValidationError), + (429, DidwwRateLimitedError), (500, DidwwServerError), (503, DidwwServerError), (418, DidwwApiError), @@ -98,6 +122,32 @@ def test_status_maps_to_an_exception_class(self, status: int, cls: type) -> None with pytest.raises(cls): decode_verification(HttpOutcome(status, body)) + def test_a_429_carries_the_cooldown_slug_and_retry_after(self) -> None: + body = b'{"errors":[{"code":"destination_in_cooldown","detail":"try again shortly"}]}' + with pytest.raises(DidwwRateLimitedError) as excinfo: + decode_verification(HttpOutcome(429, body, {"retry-after": "17"})) + assert excinfo.value.retry_after == 17 + assert excinfo.value.has_code("destination_in_cooldown") + + @pytest.mark.parametrize( + "headers", + [ + {}, + {"retry-after": "soon"}, + {"retry-after": "-5"}, + {"retry-after": "1_0"}, + {"retry-after": "1.5"}, + {"retry-after": ""}, + ], + ids=["absent", "unparsable", "negative", "underscored", "decimal", "empty"], + ) + def test_a_429s_retry_after_is_none_when_it_cannot_be_read( + self, headers: dict[str, str] + ) -> None: + with pytest.raises(DidwwRateLimitedError) as excinfo: + decode_verification(HttpOutcome(429, b'{"errors":[]}', headers)) + assert excinfo.value.retry_after is None + def test_every_error_in_the_envelope_is_kept(self) -> None: """A validation failure returns one entry per field, so errors[0] is not enough.""" body = json.dumps( diff --git a/tests/test_transport_invariants.py b/tests/test_transport_invariants.py index 07810b8..c25f1df 100644 --- a/tests/test_transport_invariants.py +++ b/tests/test_transport_invariants.py @@ -17,6 +17,7 @@ ApplicationAuth, BasicAuth, DidwwApiError, + DidwwRateLimitedError, DidwwServerError, PublicAuth, VerificationClient, @@ -142,3 +143,14 @@ def test_a_write_is_never_retried(self) -> None: destination="+37112345678", delivery_method="sms" ) assert len(seen) == 1 + + def test_a_429_on_start_is_surfaced_once_and_never_retried(self) -> None: + """A cooldown is not a transient fault: repeating the POST would charge again.""" + transport, seen = recording_transport( + status=429, body=b'{"errors":[{"code":"destination_in_cooldown"}]}' + ) + with pytest.raises(DidwwRateLimitedError): + client_with(transport, BasicAuth("k", "s")).start_verification( + destination="+37112345678", delivery_method="sms" + ) + assert len(seen) == 1 diff --git a/tests/test_vocabulary.py b/tests/test_vocabulary.py index 1f9337d..80e6add 100644 --- a/tests/test_vocabulary.py +++ b/tests/test_vocabulary.py @@ -31,9 +31,9 @@ def test_literal_members_equal_tuple_members(self, alias: object, tup: tuple[str class TestShape: - def test_the_registry_has_27_slugs(self) -> None: - """18 envelope plus 9 outcome, as the service defines them.""" - assert len(v.API_ERROR_CODES) == 27 + def test_the_registry_has_28_slugs(self) -> None: + """19 envelope plus 9 outcome, as the service defines them.""" + assert len(v.API_ERROR_CODES) == 28 assert len(v.VERIFICATION_ERROR_CODES) == 9 def test_every_outcome_code_is_in_the_combined_set(self) -> None: