feat(api): add customer blocklist and manual payment retry - #279
Conversation
Spec 1.113.6 -> 1.113.13 (+5 paths, +12 schemas, none removed). Adds a new blocklist resource exposing blocklist.customers with list/create/retrieve/delete and a nested notes subresource (create/update), plus two manual retry methods on payments (retry, retrieve_retry_state). The blocklist list endpoint takes page_number/page_size and returns an items array, matching default_page_number_pagination, so it auto-detects like the other page-number lists and needs no explicit paginated flag. SubscriptionWebhookPayload is reachable through OutgoingWebhookData and the subscription.* webhooks, so it needs no explicit model mapping. Validated: 148 configured endpoints all resolve against the spec, 113 config schema refs resolve, and all 295 distinct spec $refs resolve with none dangling. The 18 uncovered spec paths are the pre-existing intentional exclusions (Swagger UI /docs routes, internal connectors and discount-customer endpoints); no newly added path is uncovered. Stainless-Generated-From: 3588152
There was a problem hiding this comment.
Review: generated code is correct; two API-design items worth raising upstream
I verified this branch locally against the mock server built from the embedded spec (1.113.13):
ruff checkclean;ruff formatclean except a pre-existing README nit that also fails onmain.pyrightreports 5 errors, all pre-existing and unrelated (optionalhttpx_aiohttpimport, two pydantic override warnings in_models.py, one deprecation intests/utils.py). Zero in the new code.- Full suite: 3548 passed, 11 skipped. The 260 blocklist/payments tests pass.
.stats.yml137 -> 145 matches exactly the 8 new methods across the 5 new paths.- Smoke-tested both
createoverloads,retrieve/list/delete, bothnotesmethods, and both payment retry methods end to end. Request bodies, query serialization (created_at_gteas ISO 8601), path templating/percent-encoding, and the empty-path-param andrequired_argsguards all behave correctly. - Verified
subscription.past_dueunwraps through the discriminated union, and that the newDatapayload classes are stillSubscriptionsubclasses at runtime, so existingisinstancechecks and code typed againstSubscriptionkeep working.
The generated code faithfully reflects the spec. The items below are about the spec/config rather than this diff, so I'm not blocking.
1. Default retry policy conflicts with the documented terminal errors on POST /payments/{payment_id}/retry
The spec documents 409 ("A payment is already in progress, or the merchant must act elsewhere first") and 429 ("The per-invoice retry guardrail refused this send") for this endpoint. Both are terminal business errors, but the shared client retry policy in _base_client.py retries 408/409/429/5xx for every method, so client.payments.retry(id) will silently re-POST a money-moving request up to max_retries (default 2) more times with backoff before surfacing the error. The 409 case is the concerning one: re-POSTing precisely when the server says a payment is already in progress is the scenario where a duplicate charge attempt or an extra consumed sends_used could occur.
This client also sets no idempotency header, so the generated internal idempotency key is never transmitted and there is no HTTP-level dedupe protection on the automatic retries.
Cheapest fix, no SDK change required: have the API return x-should-retry: false on those 409/429 responses. _should_retry honors that header ahead of the status-code rules. Alternatively disable retries for this operation in the Stainless config. Confirming the endpoint dedupes server-side would also address it. The same 409-retry behavior applies to POST /blocklist/customers ("The customer is already blocked"), though there it is only wasteful rather than risky.
2. past_due_ends_at is reachable only from webhook payloads
SubscriptionStatus now includes past_due, and the grace-period deadline is added to the webhook data payload. But Subscription itself has no past_due_ends_at, and GET /subscriptions/{id} does not return it. A consumer that polls rather than consuming webhooks can observe status == "past_due" with no way to learn when the grace period ends. Worth adding the field to the subscription response schema.
3. Minor / non-blocking
- The inlined webhook payload schema now produces 24 structurally identical
Data(Subscription)classes (12 event types x 2 variants). Naming that schema in the spec would let the generator emit one shared model instead, and would let consumers annotate against a single type. datanarrows fromSubscriptionto a per-eventDataon 20 existing webhook event models. Runtime-compatible as noted above; the only visible effect is that code which constructs these models with a plainSubscriptionwill now fail a type check. Almost certainly fine for response models, flagging for completeness.- Redundant duplicate import of
BlockedCustomerSourceinresources/blocklist/customers/customers.py(lines 31 and 33). Harmless, resolves to the same object, and matches an existing generator artifact present in 8 other resource files onmain. - The PR description covers only the blocklist and retry additions, but the diff also carries the
past_duesubscription status and webhook event, thecancelled_by_merchant_grace_period_expiredcancel reason,BalanceLedgerEntry.payout_id, andCustomer.blocked_at/blocklist_entry_id. All additive, nothing removed, but worth listing so reviewers of the changelog see them. - The "repeat the create call until
subscriptions_sweptis true" contract is documented only on the response field docstrings, so it does not surface in thecreatemethod docstring. These operations have nosummary/descriptionin the spec, which is consistent with the rest of this API, but an operation description here would be more valuable than usual given that retry loop.
Nothing security-sensitive in the diff; the new fields are ordinary business data and the embedded spec references only the public test/live hosts.
| cast_to=ManualRetryState, | ||
| ) | ||
|
|
||
| def retry( |
There was a problem hiding this comment.
The spec documents `409` ("A payment is already in progress, or the merchant must act elsewhere first") and `429` ("The per-invoice retry guardrail refused this send") for this operation. Both are terminal, but the shared retry policy in `_base_client.py` retries 408/409/429/5xx for all methods, so this call will silently re-POST a money-moving request up to `max_retries` (default 2) more times before raising. No idempotency header is sent by this client either, so the automatic retries have no HTTP-level dedupe.
Suggest returning `x-should-retry: false` on those responses (honored ahead of the status-code rules) or disabling retries for this operation in the generator config.
Spec 1.113.6 -> 1.113.13 (+5 paths, +12 schemas, none removed).
Adds a new
blocklistresource exposingblocklist.customerswithlist/create/retrieve/delete and a nested
notessubresource (create/update),plus two manual retry methods on
payments(retry,retrieve_retry_state).Generated by stlc run 33521978397 from dodopayments-sdk-config@ae64a0b.