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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions contract/wire_contract.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -35,7 +35,8 @@
"unauthorized",
"balance_insufficient",
"validation_failed",
"internal_error"
"internal_error",
"destination_in_cooldown"
],
"verificationErrorCodes": [
"dispatch_failed",
Expand All @@ -61,6 +62,7 @@
401,
402,
422,
429,
500
]
},
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions examples/polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/didww_verification/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
DidwwConfigurationError,
DidwwDecodingError,
DidwwNotFoundError,
DidwwRateLimitedError,
DidwwServerError,
DidwwTransportError,
DidwwUnauthorizedError,
Expand Down Expand Up @@ -73,6 +74,7 @@
"DidwwConfigurationError",
"DidwwDecodingError",
"DidwwNotFoundError",
"DidwwRateLimitedError",
"DidwwServerError",
"DidwwTransportError",
"DidwwUnauthorizedError",
Expand Down
47 changes: 42 additions & 5 deletions src/didww_verification/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +16,7 @@
DidwwBalanceInsufficientError,
DidwwDecodingError,
DidwwNotFoundError,
DidwwRateLimitedError,
DidwwServerError,
DidwwTransportError,
DidwwUnauthorizedError,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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"),
)


Expand All @@ -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:
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion src/didww_verification/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "1.0.0"
VERSION = "1.1.0"
2 changes: 1 addition & 1 deletion src/didww_verification/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/didww_verification/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/didww_verification/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"DidwwConfigurationError",
"DidwwDecodingError",
"DidwwNotFoundError",
"DidwwRateLimitedError",
"DidwwServerError",
"DidwwTransportError",
"DidwwUnauthorizedError",
Expand Down Expand Up @@ -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."""
8 changes: 8 additions & 0 deletions src/didww_verification/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,22 @@ 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)
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)
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/didww_verification/vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"balance_insufficient",
"validation_failed",
"internal_error",
"destination_in_cooldown",
*VERIFICATION_ERROR_CODES,
)

Expand Down Expand Up @@ -112,6 +113,7 @@
"balance_insufficient",
"validation_failed",
"internal_error",
"destination_in_cooldown",
"dispatch_failed",
"expired",
"too_many_attempts",
Expand Down
3 changes: 2 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading