Skip to content

fix: idempotency guard for agent retries (#402) + pagination for list tools (#388)#419

Open
originaljayeshsharma wants to merge 1 commit into
stripe:mainfrom
originaljayeshsharma:fix/idempotency-and-pagination
Open

fix: idempotency guard for agent retries (#402) + pagination for list tools (#388)#419
originaljayeshsharma wants to merge 1 commit into
stripe:mainfrom
originaljayeshsharma:fix/idempotency-and-pagination

Conversation

@originaljayeshsharma
Copy link
Copy Markdown

Fix #402 & #388 — Idempotency guard + pagination for list/search tools

Summary

This PR fixes two open bugs in the Python stripe_agent_toolkit:

Issue Title Label
#402 Agent-level retry creates duplicate charges bug
#388 Missing pagination (starting_after) across all list and search tools

Fix #402 — Idempotency guard against duplicate charges

Root cause

The Stripe SDK generates idempotency keys automatically for network-level retries within a single session. But when an agent framework retries a tool call as a new invocation (after a timeout, crash, or model loop) a fresh session starts with a new key, and a second charge is created.

Solution

New module stripe_agent_toolkit/idempotency.py derives a stable, deterministic idempotency key from SHA-256(tool_name + sorted_args_json) before every mutating call:

# idempotency.py (new)
def idempotency_key_for(tool_name: str, args: dict) -> str | None:
    if tool_name not in MUTATING_TOOLS:
        return None
    payload = f"{tool_name}:{json.dumps(args, sort_keys=True)}"
    return hashlib.sha256(payload.encode()).hexdigest()

Every mutating API wrapper in api.py now calls with_idempotency() and forwards the key in options={"idempotency_key": ...}:

# api.py
def create_payment_intent(client, args):
    params = with_idempotency("create_payment_intent", args)
    return client.payment_intents.create(
        params={k: v for k, v in params.items() if k != "idempotency_key"},
        options={"idempotency_key": params.get("idempotency_key")},
    )

Because the key is deterministic, a retry of the same logical call with the same arguments sends the same key to Stripe, which returns the original receipt without creating a second charge (Stripe deduplicates for 24 h).

Read-only tools (list_*, retrieve_*) are excluded — Stripe rejects idempotency keys on GET requests.

Affected tools (mutating)

create_customer, create_product, create_price, create_payment_link, create_payment_intent, create_refund, create_invoice, create_invoice_item, finalize_invoice, create_subscription, cancel_subscription, update_subscription, create_coupon


Fix #388 — Pagination (starting_after / ending_before)

Root cause

All list tools silently capped results at 100 with no way to fetch subsequent pages. The starting_after cursor accepted by the underlying Stripe REST API was never exposed.

Solution

starting_after and ending_before optional parameters are now wired through to the Stripe SDK call for every list tool:

# api.py
def list_subscriptions(client, args):
    params = {}
    if args.get("starting_after"):
        params["starting_after"] = args["starting_after"]
    if args.get("ending_before"):
        params["ending_before"] = args["ending_before"]
    ...
    return client.subscriptions.list(params=params)

For search_stripe_resources, Stripe's Search API uses page (not starting_after) as the cursor parameter. We accept starting_after for consistency and forward it internally as page.

Affected tools

list_subscriptions, list_products, list_prices, list_customers, list_invoices, list_coupons, list_payment_links, search_stripe_resources

Example — paginating through all active subscriptions

page1 = json.loads(list_subscriptions(client, {"status": "active", "limit": 100}))
last_id = page1["data"][-1]["id"]   # e.g. "sub_099"

page2 = json.loads(list_subscriptions(client, {
"status": "active",
"limit": 100,
"starting_after": last_id,
}))

Continue until has_more == False


Files changed

tools/python/stripe_agent_toolkit/
  idempotency.py          ← new: deterministic idempotency key helper
  api.py                  ← updated: idempotency + pagination on all tools

tools/python/tests/
test_fixes.py ← new: test suite (14 tests, all passing)


Test results

PASS test_same_args_same_key
PASS test_different_args_different_key
PASS test_dict_order_does_not_matter
PASS test_read_only_tools_return_none
PASS test_all_mutating_tools (13 tools)
PASS test_with_idempotency_non_destructive
PASS test_with_idempotency_read_only_unchanged
PASS test_pagination_cursor_chaining (140 records over 2 pages)
PASS test_starting_after_forwarded (list_subscriptions)
PASS test_no_cursor_omits_params
PASS test_list_products starting_after
PASS test_list_prices starting_after
PASS test_search_starting_after_maps_to_page
PASS test_idempotency_key_forwarded_to_create_payment_intent

Backwards compatibility

  • All changes are additive. No existing call signatures change.
  • Callers that don't pass starting_after / ending_before behave identically to before.
  • The idempotency key is injected transparently; callers that already pass their own idempotency_key in args will have it preserved (it will override the generated key because with_idempotency spreads args last).
# Fix #402 & #388 — Idempotency guard + pagination for list/search tools

Summary

This PR fixes two open bugs in the Python stripe_agent_toolkit:

Issue Title Label
[#402](#402) Agent-level retry creates duplicate charges bug
[#388](#388) Missing pagination (starting_after) across all list and search tools

Fix #402 — Idempotency guard against duplicate charges

Root cause

The Stripe SDK generates idempotency keys automatically for network-level
retries within a single session. But when an agent framework retries a
tool call as a new invocation (after a timeout, crash, or model loop) a
fresh session starts with a new key, and a second charge is created.

Solution

New module stripe_agent_toolkit/idempotency.py derives a stable,
deterministic idempotency key
from SHA-256(tool_name + sorted_args_json)
before every mutating call:

# idempotency.py (new)
def idempotency_key_for(tool_name: str, args: dict) -> str | None:
    if tool_name not in MUTATING_TOOLS:
        return None
    payload = f"{tool_name}:{json.dumps(args, sort_keys=True)}"
    return hashlib.sha256(payload.encode()).hexdigest()

Every mutating API wrapper in api.py now calls with_idempotency() and
forwards the key in options={"idempotency_key": ...}:

# api.py
def create_payment_intent(client, args):
    params = with_idempotency("create_payment_intent", args)
    return client.payment_intents.create(
        params={k: v for k, v in params.items() if k != "idempotency_key"},
        options={"idempotency_key": params.get("idempotency_key")},
    )

Because the key is deterministic, a retry of the same logical call with the
same arguments
sends the same key to Stripe, which returns the original
receipt without creating a second charge (Stripe deduplicates for 24 h).

Read-only tools (list_*, retrieve_*) are excluded — Stripe rejects
idempotency keys on GET requests.

Affected tools (mutating)

create_customer, create_product, create_price, create_payment_link,
create_payment_intent, create_refund, create_invoice,
create_invoice_item, finalize_invoice, create_subscription,
cancel_subscription, update_subscription, create_coupon


Fix #388 — Pagination (starting_after / ending_before)

Root cause

All list tools silently capped results at 100 with no way to fetch subsequent
pages. The starting_after cursor accepted by the underlying Stripe REST API
was never exposed.

Solution

starting_after and ending_before optional parameters are now wired through
to the Stripe SDK call for every list tool:

# api.py
def list_subscriptions(client, args):
    params = {}
    if args.get("starting_after"):
        params["starting_after"] = args["starting_after"]
    if args.get("ending_before"):
        params["ending_before"] = args["ending_before"]
    ...
    return client.subscriptions.list(params=params)

For search_stripe_resources, Stripe's Search API uses page (not
starting_after) as the cursor parameter. We accept starting_after for
consistency and forward it internally as page.

Affected tools

list_subscriptions, list_products, list_prices, list_customers,
list_invoices, list_coupons, list_payment_links,
search_stripe_resources

Example — paginating through all active subscriptions

page1 = json.loads(list_subscriptions(client, {"status": "active", "limit": 100}))
last_id = page1["data"][-1]["id"]   # e.g. "sub_099"

page2 = json.loads(list_subscriptions(client, {
    "status": "active",
    "limit": 100,
    "starting_after": last_id,
}))
# Continue until has_more == False

Files changed

tools/python/stripe_agent_toolkit/
  idempotency.py          ← new: deterministic idempotency key helper
  api.py                  ← updated: idempotency + pagination on all tools

tools/python/tests/
  test_fixes.py           ← new: test suite (14 tests, all passing)

Test results

PASS test_same_args_same_key
PASS test_different_args_different_key
PASS test_dict_order_does_not_matter
PASS test_read_only_tools_return_none
PASS test_all_mutating_tools (13 tools)
PASS test_with_idempotency_non_destructive
PASS test_with_idempotency_read_only_unchanged
PASS test_pagination_cursor_chaining (140 records over 2 pages)
PASS test_starting_after_forwarded (list_subscriptions)
PASS test_no_cursor_omits_params
PASS test_list_products starting_after
PASS test_list_prices starting_after
PASS test_search_starting_after_maps_to_page
PASS test_idempotency_key_forwarded_to_create_payment_intent

Backwards compatibility

  • All changes are additive. No existing call signatures change.
  • Callers that don't pass starting_after / ending_before behave
    identically to before.
  • The idempotency key is injected transparently; callers that already pass
    their own idempotency_key in args will have it preserved (it will
    override the generated key because with_idempotency spreads args last).

@cla-assistant
Copy link
Copy Markdown

cla-assistant Bot commented May 11, 2026

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant