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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,11 @@ print(resent["message_id"]) # the new message; it links back through resent_fro
| `team` | `retrieve`, `update`, `usage`, `roles` |
| `team.members` | `list`, `iterate`, `retrieve`, `update_assignment` |
| `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerate_secret` |
| `webhooks.deliveries` | `list(webhook_id)`, `iterate(webhook_id)`, `retrieve(webhook_id, delivery_id)` |
| `webhooks.deliveries` | `list(webhook_id)`, `iterate(webhook_id)`, `retrieve(webhook_id, delivery_id)`, `retry(webhook_id, delivery_id)` |
| (root) | `ping`, `analytics`, `analytics_pages`, `blocked_file_types` |

`webhooks.deliveries.retry()` sends a delivery's original payload to the webhook again now. The retry counts as a delivery attempt; if it fails with a retryable error, the automatic retries continue. The API answers `409` (`ConflictError`) when the webhook is disabled, the delivery cannot be retried or a retry is already in progress.

### Query parameters and pagination

Query parameters are typed nested dicts. The SDK sends them in the API's bracket syntax (`page[size]=30&filter[status]=verified&sort=-created_at`):
Expand Down
20 changes: 20 additions & 0 deletions src/lettermint/_async/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
RescheduleMessageRequest,
ResendMessageRequest,
ResendReportForwardingCodeResponse,
RetryWebhookDeliveryResponse,
RotateProjectTokenResponse,
RouteData,
RouteListData,
Expand Down Expand Up @@ -889,6 +890,25 @@ async def retrieve(
),
)

async def retry(
self, webhook_id: str, delivery_id: str, *, timeout: float | None = None
) -> RetryWebhookDeliveryResponse:
"""Sends the delivery's original payload to the webhook again now.

The retry counts as a delivery attempt; when it fails with a retryable error, the
automatic retries continue. The API answers ``409`` (a :class:`~lettermint.ConflictError`)
when the webhook is disabled, the delivery cannot be retried or a retry is in progress.
"""
return cast(
RetryWebhookDeliveryResponse,
await self._request(
"POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry",
"webhooks.deliveries.retry",
path={"webhookId": webhook_id, "deliveryId": delivery_id},
timeout=timeout,
),
)


class AsyncWebhooks(AsyncResource):
"""Webhook endpoints. Team token. To verify incoming deliveries, use :class:`~lettermint.Webhook`."""
Expand Down
4 changes: 2 additions & 2 deletions src/lettermint/_generated/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Generated by lettermint/sdk-generator — do not edit.
# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main)
# Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main)
# sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc
# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593
# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7
# Naming profile: next
"""Generated from the Lettermint OpenAPI specifications. Do not edit; regenerate."""
26 changes: 21 additions & 5 deletions src/lettermint/_generated/operations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Generated by lettermint/sdk-generator — do not edit.
# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main)
# Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main)
# sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc
# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593
# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7
# Naming profile: next
"""Every operation of the Sending and Team APIs, keyed by ``"<METHOD> <path>"``.

Expand Down Expand Up @@ -557,7 +557,7 @@ class Operation:
headers=(),
request=None,
response=ResponseBody(status=(200,), content_type="application/json", type="ListWebhookDeliveriesResponse"),
errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)},
errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")},
pagination=Pagination(style="cursor", items="WebhookDeliveryListData", cursor_param="cursor", size_param=None),
deprecated=False,
),
Expand All @@ -573,7 +573,7 @@ class Operation:
headers=(),
request=None,
response=ResponseBody(status=(200,), content_type="application/json", type="WebhookDeliveryData"),
errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ApiErrorBody", "ErrorMessage"), 429: ("TooManyRequests",)},
errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ApiErrorBody", "ErrorMessage"), 429: ("ErrorMessage", "ApiErrorBody")},
pagination=None,
deprecated=False,
),
Expand Down Expand Up @@ -866,6 +866,22 @@ class Operation:
pagination=None,
deprecated=False,
),
# Retry a webhook delivery
"POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry": Operation(
operation_id="retryWebhookDelivery",
method="POST",
path="/webhooks/{webhookId}/deliveries/{deliveryId}/retry",
auth="team",
surfaces=("team",),
path_params=("webhookId", "deliveryId"),
query=None,
headers=(),
request=None,
response=ResponseBody(status=(202,), content_type="application/json", type="RetryWebhookDeliveryResponse"),
errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ErrorMessage", "ApiErrorBody"), 404: ("ApiErrorBody", "ErrorMessage"), 409: ("ApiErrorBody",), 429: ("ErrorMessage", "ApiErrorBody")},
pagination=None,
deprecated=False,
),
# Regenerate webhook secret
"POST /webhooks/{webhookId}/regenerate-secret": Operation(
operation_id="regenerateWebhookSecret",
Expand All @@ -878,7 +894,7 @@ class Operation:
headers=(),
request=None,
response=ResponseBody(status=(200,), content_type="application/json", type="WebhookSecretResponse"),
errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)},
errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")},
pagination=None,
deprecated=False,
),
Expand Down
25 changes: 23 additions & 2 deletions src/lettermint/_generated/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Generated by lettermint/sdk-generator — do not edit.
# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main)
# Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main)
# sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc
# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593
# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7
# Naming profile: next
"""Request and response types of the Lettermint API.

Expand Down Expand Up @@ -164,6 +164,7 @@
"RescheduleMessageRequest",
"ResendMessageRequest",
"ResendReportForwardingCodeResponse",
"RetryWebhookDeliveryResponse",
"RotateProjectTokenResponse",
"RouteData",
"RouteDataSettings",
Expand Down Expand Up @@ -1657,6 +1658,8 @@ class CursorPage(TypedDict, Generic[T]):
"duration_ms": Required[int | None],
"delivered_at": Required[str | None],
"created_at": Required[str],
# When the next automatic retry is due. Null when none is scheduled
"next_retry_at": NotRequired[str | None],
},
)

Expand Down Expand Up @@ -2154,6 +2157,18 @@ class CursorPage(TypedDict, Generic[T]):
)


# Confirmation that a manual retry of a delivery was queued.
RetryWebhookDeliveryResponse = TypedDict(
"RetryWebhookDeliveryResponse",
{
# Human-readable confirmation
"message": Required[str],
# The ID of the delivery that is retried
"delivery_id": Required[str],
},
)


# The project and its new legacy Project API token.
RotateProjectTokenResponse = TypedDict(
"RotateProjectTokenResponse",
Expand Down Expand Up @@ -2979,12 +2994,18 @@ class CursorPage(TypedDict, Generic[T]):
"attempt_number": Required[int],
"http_status_code": Required[int | None],
"duration_ms": Required[int | None],
# Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token.
"payload": Required[list[str]],
# Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token.
"response_body": Required[str | None],
# Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token.
"response_headers": Required[list[str] | None],
# Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token.
"error_message": Required[str | None],
"delivered_at": Required[str | None],
"timestamp": Required[str],
# When the next automatic retry is due. Null when none is scheduled
"next_retry_at": NotRequired[str | None],
},
)

Expand Down
20 changes: 20 additions & 0 deletions src/lettermint/_sync/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
RescheduleMessageRequest,
ResendMessageRequest,
ResendReportForwardingCodeResponse,
RetryWebhookDeliveryResponse,
RotateProjectTokenResponse,
RouteData,
RouteListData,
Expand Down Expand Up @@ -890,6 +891,25 @@ def retrieve(
),
)

def retry(
self, webhook_id: str, delivery_id: str, *, timeout: float | None = None
) -> RetryWebhookDeliveryResponse:
"""Sends the delivery's original payload to the webhook again now.

The retry counts as a delivery attempt; when it fails with a retryable error, the
automatic retries continue. The API answers ``409`` (a :class:`~lettermint.ConflictError`)
when the webhook is disabled, the delivery cannot be retried or a retry is in progress.
"""
return cast(
RetryWebhookDeliveryResponse,
self._request(
"POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry",
"webhooks.deliveries.retry",
path={"webhookId": webhook_id, "deliveryId": delivery_id},
timeout=timeout,
),
)


class Webhooks(Resource):
"""Webhook endpoints. Team token. To verify incoming deliveries, use :class:`~lettermint.Webhook`."""
Expand Down
7 changes: 6 additions & 1 deletion tests/test_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@
("w1", "dl1"),
"GET /webhooks/{webhookId}/deliveries/{deliveryId}",
),
(
"webhooks.deliveries.retry",
("w1", "dl1"),
"POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry",
),
]

LAYOUT = {
Expand Down Expand Up @@ -191,7 +196,7 @@
"test",
"update",
],
"webhooks.deliveries": ["iterate", "list", "retrieve"],
"webhooks.deliveries": ["iterate", "list", "retrieve", "retry"],
"emails": ["compose", "ping", "send", "send_batch"],
"": ["analytics", "analytics_pages", "blocked_file_types", "close", "ping"],
}
Expand Down
137 changes: 137 additions & 0 deletions tests/test_webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""``webhooks.deliveries.retry`` and the delivery fields: the request, the token and the errors."""

from __future__ import annotations

import inspect
from typing import Any

import pytest

from lettermint import (
APIError,
AsyncLettermint,
ConflictError,
Lettermint,
LettermintConfigError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
)
from lettermint.types import RetryWebhookDeliveryResponse, WebhookDeliveryData

from .conftest import BASE_URL, SENDING_TOKEN, TEAM_TOKEN, MockAPI

QUEUED = {"message": "Webhook delivery retry queued.", "delivery_id": "dl1"}


def test_retry_posts_without_a_body_with_the_team_token(api: MockAPI, client: Lettermint) -> None:
api.json(202, QUEUED)
result: RetryWebhookDeliveryResponse = client.webhooks.deliveries.retry("w1", "dl1")
assert result == QUEUED
assert api.last.method == "POST"
assert str(api.last.url) == f"{BASE_URL}/webhooks/w1/deliveries/dl1/retry"
assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}"
assert "x-lettermint-token" not in api.last.headers
assert api.last.content == b""
assert "content-type" not in api.last.headers
assert "idempotency-key" not in api.last.headers


def test_retry_takes_no_idempotency_key() -> None:
for cls in (Lettermint, AsyncLettermint):
client = cls(team_token=TEAM_TOKEN)
parameters = inspect.signature(client.webhooks.deliveries.retry).parameters
assert list(parameters) == ["webhook_id", "delivery_id", "timeout"]


def test_retry_needs_the_team_token(api: MockAPI) -> None:
sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client())
with pytest.raises(
LettermintConfigError, match=r"^webhooks\.deliveries\.retry needs team_token"
):
sending_only.webhooks.deliveries.retry("w1", "dl1")
assert api.requests == []


def test_retry_encodes_and_checks_both_ids(api: MockAPI, client: Lettermint) -> None:
api.json(202, QUEUED)
client.webhooks.deliveries.retry("a/b", "c d")
assert api.last.url.raw_path == b"/v1/webhooks/a%2Fb/deliveries/c%20d/retry"
with pytest.raises(LettermintConfigError, match=r"^webhooks\.deliveries\.retry: webhookId"):
client.webhooks.deliveries.retry("", "dl1")
with pytest.raises(LettermintConfigError, match=r"^webhooks\.deliveries\.retry: deliveryId"):
client.webhooks.deliveries.retry("w1", "..")
assert len(api.requests) == 1


def test_a_delivery_that_cannot_be_retried_is_a_conflict(api: MockAPI, client: Lettermint) -> None:
body = {
"error": {
"code": "WEBHOOK_DELIVERY_NOT_RETRYABLE",
"message": "Only failed deliveries can be retried.",
}
}
api.json(409, body)
with pytest.raises(ConflictError) as caught:
client.webhooks.deliveries.retry("w1", "dl1")
assert isinstance(caught.value, APIError)
assert (caught.value.status, caught.value.code, caught.value.body) == (
409,
"WEBHOOK_DELIVERY_NOT_RETRYABLE",
body,
)


@pytest.mark.parametrize(
("status", "body", "cls", "code"),
[
(404, {"message": "Not found."}, NotFoundError, None),
(
403,
{"error": {"code": "FORBIDDEN", "message": "Forbidden."}},
PermissionDeniedError,
"FORBIDDEN",
),
(403, {"message": "This action is unauthorized."}, PermissionDeniedError, None),
(429, {"message": "Too Many Attempts."}, RateLimitError, None),
(
429,
{"error": {"code": "RATE_LIMITED", "message": "Slow down."}},
RateLimitError,
"RATE_LIMITED",
),
],
)
def test_retry_errors_in_either_body_shape(
api: MockAPI, client: Lettermint, status: int, body: Any, cls: type[APIError], code: str | None
) -> None:
api.json(status, body, **{"Retry-After": "7"})
with pytest.raises(cls) as caught:
client.webhooks.deliveries.retry("w1", "dl1")
assert type(caught.value) is cls
assert (caught.value.status, caught.value.code, caught.value.body) == (status, code, body)
if cls is RateLimitError:
assert caught.value.retry_after == 7 # type: ignore[attr-defined]


@pytest.mark.anyio
async def test_async_retry(api: MockAPI, aclient: AsyncLettermint) -> None:
api.json(202, QUEUED)
result: RetryWebhookDeliveryResponse = await aclient.webhooks.deliveries.retry("w1", "dl1")
assert result == QUEUED
assert api.last.method == "POST"
assert str(api.last.url) == f"{BASE_URL}/webhooks/w1/deliveries/dl1/retry"
assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}"
assert api.last.content == b""


@pytest.mark.parametrize("next_retry_at", ["2026-10-18T12:05:00Z", None])
def test_deliveries_carry_next_retry_at(
api: MockAPI, client: Lettermint, next_retry_at: str | None
) -> None:
api.json(200, {"id": "dl1", "status": "failed", "next_retry_at": next_retry_at})
delivery: WebhookDeliveryData = client.webhooks.deliveries.retrieve("w1", "dl1")
assert delivery["next_retry_at"] == next_retry_at
api.json(200, {"data": [{"id": "dl1", "next_retry_at": next_retry_at}], "next_cursor": None})
page = client.webhooks.deliveries.list("w1")
assert page["data"][0]["next_retry_at"] == next_retry_at
Loading