Restate Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Restate is a durable execution runtime that persists a journal for every handler invocation — recording side effects, service-to-service calls, and timer results so that a crashed handler can replay to its last committed state without re-executing completed work. AI teams use Restate to build resilient billing workflows, LLM agent pipelines, and subscription management services that survive process restarts, network partitions, and deployment rollouts. When a Restate handler calls stripe.charges.create(), Restate's durability model introduces three specific failure modes that neither Stripe restricted keys nor standard idempotency-key discipline fully prevents without understanding how the journal works.
This post covers all three failure modes with Restate Python SDK code, and the two-layer governance pattern — content-hash idempotency keys plus per-handler vault keys via a spend-cap proxy — that eliminates all three without restructuring your service topology.
Failure mode 1: ctx.run() exception before journal commit causes retry, which fires a second stripe.charges.create()
Restate's ctx.run() is the side-effect primitive. When your handler calls await ctx.run("charge customer", lambda: stripe.charges.create(...)), Restate executes the lambda, waits for it to return, persists the return value to the journal, and returns the journaled value to your handler code. On replay — after a crash, a restart, or a network partition — Restate re-runs handler code up to each journaled entry and then returns the persisted value without re-executing the lambda. The lambda does not run again. This is Restate's exactly-once guarantee for side effects.
The guarantee holds when the lambda returns successfully. When the lambda raises an exception, the result is not journaled — Restate treats the side effect as incomplete and retries the entire ctx.run() block with exponential backoff. This is correct behavior for transient infrastructure errors: if a database write fails, you want Restate to retry it. But for Stripe API calls, there is a category of error where the charge succeeds on Stripe's side but the response never reaches your handler: a network glitch that severs the connection after Stripe processes the request but before the HTTP response returns. The stripe-python SDK raises stripe.error.APIConnectionError. From Restate's perspective, the lambda raised — so it retries. From Stripe's perspective, the charge is already in the succeeded state. Without an idempotency key, the retry creates ch_B.
# billing_service.py — UNSAFE: no idempotency key inside ctx.run()
import restate
from restate import Service, Context
import stripe
import os
billing_service = Service("BillingService")
@billing_service.handler()
async def charge_customer(ctx: Context, request: dict) -> dict:
customer_id = request["customer_id"]
amount_cents = request["amount_cents"]
billing_period = request["billing_period"]
# UNSAFE: ctx.run() wraps the side effect correctly, but no idempotency key.
# If stripe.charges.create() fires and Stripe creates ch_A, then a network
# glitch severs the connection before the 200 response arrives:
# - stripe-python raises APIConnectionError
# - ctx.run() lambda raised → result not journaled
# - Restate retries ctx.run() with exponential backoff
# - lambda runs again → stripe.charges.create() fires again → ch_B created
# - Both ch_A and ch_B are "succeeded" in Stripe; customer is billed twice
charge = await ctx.run(
"charge stripe",
lambda: stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Billing {billing_period}",
api_key=os.environ["STRIPE_SECRET_KEY"],
)
)
return {"charge_id": charge["id"], "status": "charged"}
The danger is specific to the network-partition window: the charge succeeded, but your process doesn't know it. Restate's retry logic — which is exactly what you want for all other infrastructure errors — becomes the mechanism for the double charge. This failure mode is invisible in testing: your test environment's Stripe calls are either mocked or hit a fast local network where connection errors don't split the request from the response. It surfaces in production during high-load periods, infrastructure restarts, or cloud provider network events.
The fix is a content-hash idempotency key derived deterministically from the billing event's immutable fields — customer ID, amount, and billing period — combined with a Restate-specific salt. Because the same billing event always produces the same idempotency key regardless of how many times Restate retries the ctx.run() block, Stripe returns the original ch_A object on the retry rather than creating ch_B.
# billing_service.py — SAFE: content-hash idempotency key inside ctx.run()
import restate
from restate import Service, Context
import stripe
import hashlib
import os
billing_service = Service("BillingService")
def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:restate-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
@billing_service.handler()
async def charge_customer(ctx: Context, request: dict) -> dict:
customer_id = request["customer_id"]
amount_cents = request["amount_cents"]
billing_period = request["billing_period"]
vault_key = request.get("vault_key") or os.environ["KEYBRAKE_VAULT_KEY"]
idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
def _charge():
stripe_client = stripe.Stripe(
api_key=vault_key,
base_url="https://proxy.keybrake.com/stripe",
)
return stripe_client.charges.create(
params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"description": f"Billing {billing_period}",
},
options={"idempotency_key": idempotency_key},
)
# On ctx.run() retry (after APIConnectionError), the same idempotency key
# is sent to Stripe. Stripe returns the original charge object — no new charge.
charge = await ctx.run("charge stripe", _charge)
return {"charge_id": charge.id, "status": "charged", "billing_period": billing_period}
Note the lambda captures vault_key and idempotency_key by closure — both are deterministic before the ctx.run() call and do not depend on any external state that could change between retry attempts. Also note that permanent Stripe errors (card declined, invalid customer ID) should be surfaced as structured return values rather than re-raised, because a raised exception causes Restate to retry the ctx.run() block indefinitely. Catch stripe.error.CardError and stripe.error.InvalidRequestError inside the lambda, and return a structured error dict so the handler can route the failure appropriately.
Failure mode 2: fan-out via parallel service calls shares one unrestricted STRIPE_SECRET_KEY across all concurrent billing handlers
Restate supports fan-out by dispatching multiple service-to-service calls from a parent handler and awaiting all results. In a monthly billing run, an orchestrator handler iterates over a customer list and dispatches a charge_customer call for each one. In the Restate Python SDK, parallel fan-out uses ctx.service_call() with asyncio.gather() to await all results concurrently.
Each charge_customer handler invocation runs in its own Restate worker process or coroutine. All worker invocations load STRIPE_SECRET_KEY from the environment — the same key across every concurrent handler. The key has no per-handler or per-invocation dollar cap. If a unit calculation error in the upstream data pipeline produces an incorrect amount_cents — wrong currency factor, unhandled decimal, a misconfigured billing formula — every concurrent charge handler fires stripe.charges.create() with the wrong amount simultaneously. Because the fan-out dispatches all handlers before any result returns, the error propagates to the entire customer cohort before any result is observed and any alerting can fire. Customers are overcharged across the board, and the only recovery is a wave of manual refunds.
# billing_orchestrator.py — UNSAFE: fan-out shares one STRIPE_SECRET_KEY, no per-handler cap
import restate
from restate import Service, Context
import asyncio
billing_orchestrator = Service("BillingOrchestrator")
@billing_orchestrator.handler()
async def run_monthly_billing(ctx: Context, request: dict) -> dict:
customer_ids = request["customer_ids"]
amount_cents = request["amount_cents"]
billing_period = request["billing_period"]
# UNSAFE: dispatches N concurrent charge_customer handlers.
# Each handler loads STRIPE_SECRET_KEY from env — no per-handler dollar cap.
# A data error in amount_cents hits all N customers simultaneously.
results = await asyncio.gather(*[
ctx.service_call(
billing_service.charge_customer,
arg={
"customer_id": c_id,
"amount_cents": amount_cents,
"billing_period": billing_period,
# No vault_key passed — each handler falls back to bare STRIPE_SECRET_KEY
}
)
for c_id in customer_ids
])
return {"billed": len(results), "billing_period": billing_period}
The fix is to issue one vault key per customer before the fan-out dispatch — or one vault key per billing cohort if you want a single cap across the group. Vault keys issued by Keybrake carry a total dollar cap: the expected charge amount plus a 10% buffer per customer. A data error that doubles the amount exhausts the vault key cap after the first overcharge attempt. The proxy returns a 402 error, the handler surfaces it as a structured error record, and the remaining concurrent handlers are blocked before they charge. The blast radius is capped at one customer rather than the entire cohort.
# billing_orchestrator.py — SAFE: per-customer vault keys issued before fan-out
import restate
from restate import Service, Context
import asyncio
import aiohttp
import os
billing_orchestrator = Service("BillingOrchestrator")
async def issue_vault_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
"""Issue a vault key capped at this customer's expected charge + 10%."""
keybrake_api_key = os.environ["KEYBRAKE_API_KEY"]
daily_usd_cap = int((amount_cents * 1.1) / 100) + 1
async with aiohttp.ClientSession() as session:
async with session.post(
"https://proxy.keybrake.com/vault/keys",
headers={
"Authorization": f"Bearer {keybrake_api_key}",
"Content-Type": "application/json",
},
json={
"vendor": "stripe",
"daily_usd_cap": daily_usd_cap,
"allowed_endpoints": ["POST /v1/charges"],
"expires_in_seconds": 3600,
"label": f"restate-billing/{billing_period}/{customer_id}",
},
) as resp:
data = await resp.json()
return data["vault_key"]
@billing_orchestrator.handler()
async def run_monthly_billing(ctx: Context, request: dict) -> dict:
customer_ids = request["customer_ids"]
amount_cents = request["amount_cents"]
billing_period = request["billing_period"]
# Issue per-customer vault keys before fan-out.
# Wrap key issuance in ctx.run() so the vault key is journaled —
# on replay, Restate returns the same vault key without issuing a new one.
vault_keys = {}
for c_id in customer_ids:
vault_key = await ctx.run(
f"issue vault key {c_id}",
lambda cid=c_id: issue_vault_key(cid, amount_cents, billing_period)
)
vault_keys[c_id] = vault_key
# Fan-out: each handler receives its own capped vault key.
# A data error in amount_cents exhausts the per-customer cap (≈amount_cents × 1.1)
# and the proxy returns 402 — only that one customer's handler is blocked.
results = await asyncio.gather(*[
ctx.service_call(
billing_service.charge_customer,
arg={
"customer_id": c_id,
"amount_cents": amount_cents,
"billing_period": billing_period,
"vault_key": vault_keys[c_id],
}
)
for c_id in customer_ids
], return_exceptions=True)
charged = sum(1 for r in results if isinstance(r, dict) and r.get("status") == "charged")
errors = sum(1 for r in results if isinstance(r, Exception) or
(isinstance(r, dict) and r.get("status") != "charged"))
return {"billed": charged, "errors": errors, "billing_period": billing_period}
Note that the vault key issuance itself is wrapped in ctx.run(). This means the vault key is journaled: if the orchestrator handler crashes after issuing keys but before dispatching the fan-out, Restate replays the handler, returns the journaled vault keys from the journal (without making new key issuance API calls), and dispatches the fan-out with the same keys. The idempotency properties stack: the vault key issuance is idempotent via journal, and the Stripe charges are idempotent via content-hash idempotency keys.
Failure mode 3: Stripe calls outside ctx.run() re-execute on every journal replay
Restate's replay mechanism works by re-running handler code from the beginning, but skipping the execution of any code wrapped in ctx.run(), ctx.service_call(), ctx.object_call(), or any other Restate journaling primitive — returning the persisted journal entry instead. Any code in the handler body that is not wrapped in a Restate journaling primitive runs on every replay. This is intentional for computation (pure functions, in-memory operations, control flow) and problematic for side effects.
The failure mode arises when a developer calls stripe.charges.create() directly in the handler body, outside a ctx.run() block. This can happen accidentally — after refactoring a synchronous billing function into a Restate handler, the Stripe call is in the handler body and the developer adds ctx.run() for only the downstream database write, not realizing that the Stripe call also needs wrapping. It can also happen by design in early handler prototypes that predate full journaling discipline. Regardless of the cause, the result is the same: every time Restate replays this handler — after a crash, during a worker restart, after a deployment rollout that interrupted an in-flight request — the direct stripe.charges.create() call executes again.
# billing_handler.py — UNSAFE: Stripe call outside ctx.run() re-executes on replay
import restate
from restate import VirtualObject, ObjectContext
import stripe
import os
customer_billing = VirtualObject("CustomerBilling")
@customer_billing.handler()
async def bill_for_period(ctx: ObjectContext, request: dict) -> dict:
customer_id = ctx.key()
amount_cents = request["amount_cents"]
billing_period = request["billing_period"]
# UNSAFE: Stripe call is outside ctx.run().
# On journal replay after a crash, this line executes again.
# If the first execution created ch_A (and then crashed before the
# ctx.run("record charge") call below committed), replay creates ch_B.
charge = stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Billing {billing_period}",
api_key=os.environ["STRIPE_SECRET_KEY"],
)
# Only the database write is journaled — not the Stripe call.
# Restate skips this on replay (returns the journaled result),
# but the Stripe call above it has already executed again by then.
result = await ctx.run(
"record charge in db",
lambda: record_charge_to_database(customer_id, charge["id"], billing_period)
)
return {"charge_id": charge["id"], "status": "charged"}
The fix is to wrap the Stripe call in ctx.run() just as you would wrap any other external side effect. When the handler replays, Restate returns the journaled charge object from the first successful execution without re-calling Stripe. If the first execution failed before journaling (because the Stripe call itself raised an exception), the ctx.run() block is retried — and the idempotency key ensures Stripe deduplicates the retry.
# billing_handler.py — SAFE: Stripe call inside ctx.run(), idempotency key in closure
import restate
from restate import VirtualObject, ObjectContext
import stripe
import hashlib
import os
customer_billing = VirtualObject("CustomerBilling")
def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:restate-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
@customer_billing.handler()
async def bill_for_period(ctx: ObjectContext, request: dict) -> dict:
customer_id = ctx.key()
amount_cents = request["amount_cents"]
billing_period = request["billing_period"]
vault_key = request.get("vault_key") or os.environ["KEYBRAKE_VAULT_KEY"]
idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
def _charge():
stripe_client = stripe.Stripe(
api_key=vault_key,
base_url="https://proxy.keybrake.com/stripe",
)
return stripe_client.charges.create(
params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"description": f"Billing {billing_period}",
},
options={"idempotency_key": idempotency_key},
)
# Stripe call is journaled: on replay, Restate returns the persisted charge object.
# On ctx.run() retry (exception before journal commit), idempotency key prevents
# a duplicate charge.
charge = await ctx.run("charge stripe", _charge)
# Database write is also journaled — both side effects are protected.
await ctx.run(
"record charge in db",
lambda: record_charge_to_database(customer_id, charge.id, billing_period)
)
return {"charge_id": charge.id, "status": "charged", "billing_period": billing_period}
For VirtualObject handlers, you can also use Restate's virtual object state (ctx.set() / ctx.get()) to record that a customer has been billed for a given period, and implement a pre-flight check at the top of the handler. This is a complementary protection that prevents the Stripe call from even being attempted if the period is already marked as billed — useful for scenarios where the idempotency key's 24-hour window has expired or where the billing event arrives via a different handler invocation context.
# Pre-flight check using VirtualObject state — complementary to idempotency keys
@customer_billing.handler()
async def bill_for_period(ctx: ObjectContext, request: dict) -> dict:
customer_id = ctx.key()
billing_period = request["billing_period"]
# Check if already billed for this period (persisted in Restate virtual object state)
billed_periods: list = await ctx.get("billed_periods") or []
if billing_period in billed_periods:
return {"charge_id": None, "status": "already_billed", "billing_period": billing_period}
# ... (charge logic with ctx.run() and idempotency key as above) ...
# Record this period as billed in virtual object state
billed_periods.append(billing_period)
await ctx.set("billed_periods", billed_periods)
return {"charge_id": charge.id, "status": "charged", "billing_period": billing_period}
Approach comparison
| Approach | ctx.run() retry safe? | Fan-out cap? | Replay-safe? | Per-handler audit? | Mid-run revoke? |
|---|---|---|---|---|---|
| Stripe call outside ctx.run(), no idem key | No — re-executes on every replay | No | No | No | No |
| Inside ctx.run(), no idempotency key | No — retry creates new charge | No | Yes (if no ctx.run() exception) | No | No |
| Stripe restricted key (no proxy) | No | No — endpoint scope only, no dollar cap | No | No | No |
| ctx.run() + content-hash idempotency key only | Yes (within 24h Stripe window) | No — no dollar cap across concurrent handlers | Yes | No | No |
| VirtualObject state pre-flight check only | Partial — only prevents re-entry after first successful run | No | Yes (state is durable) | No | No |
| ctx.run() + idem key + per-customer vault key | Yes | Yes — dollar cap per handler at proxy layer | Yes | Yes | Yes — single DELETE /vault/keys/{id} |
pytest enforcement suite
# test_restate_billing.py
import hashlib
import pytest
def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:restate-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def test_idempotency_key_stable_across_ctx_run_retries():
"""Same billing request → same key regardless of how many times ctx.run() retries."""
key1 = billing_idempotency_key("cus_abc", 4999, "2026-07")
key2 = billing_idempotency_key("cus_abc", 4999, "2026-07")
assert key1 == key2
assert len(key1) == 32
def test_idempotency_key_differs_across_customers():
key_a = billing_idempotency_key("cus_abc", 4999, "2026-07")
key_b = billing_idempotency_key("cus_xyz", 4999, "2026-07")
assert key_a != key_b
def test_idempotency_key_differs_across_periods():
key_jun = billing_idempotency_key("cus_abc", 4999, "2026-06")
key_jul = billing_idempotency_key("cus_abc", 4999, "2026-07")
assert key_jun != key_jul
def test_vault_key_not_bare_stripe_key(monkeypatch):
monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_live_real_secret")
monkeypatch.setenv("KEYBRAKE_VAULT_KEY", "kb_vault_test_abc123")
import os
vault_key = os.environ["KEYBRAKE_VAULT_KEY"]
assert vault_key.startswith("kb_"), "Restate billing handler must use vault key, not bare Stripe key"
assert vault_key != os.environ["STRIPE_SECRET_KEY"]
def test_permanent_stripe_errors_do_not_raise():
"""Permanent errors should be returned as structured dicts, not raised."""
# Simulates the lambda inside ctx.run() handling CardError without raising
def mock_charge_lambda_with_card_error():
try:
raise Exception("card_declined") # simulates stripe.error.CardError
except Exception as e:
return {"charge_id": None, "status": f"card_declined:{e}"}
result = mock_charge_lambda_with_card_error()
assert result["charge_id"] is None
assert "card_declined" in result["status"]
Gap analysis
1. Restate's built-in retry policy for ctx.run() uses exponential backoff with jitter, but the retry count is unbounded by default — permanent Stripe errors loop indefinitely
When a ctx.run() lambda raises an exception, Restate retries it with exponential backoff: 100ms, 200ms, 400ms, and so on up to a configurable maximum interval. The retry count is unbounded by default. For transient infrastructure errors (network timeouts, DNS resolution failures), this is the correct behavior: Restate will eventually succeed when the infrastructure recovers. For permanent Stripe errors — card declined, invalid customer ID, currency mismatch, Stripe account suspended — retrying is counterproductive: the same call will fail on every retry, and the handler will loop indefinitely, consuming Restate worker resources and generating error logs until someone manually cancels the invocation. Always catch permanent Stripe error types inside the lambda and return a structured error dict rather than re-raising. Catching stripe.error.CardError, stripe.error.InvalidRequestError, and stripe.error.PermissionError and returning them as structured output is the idiomatic Restate pattern for non-retriable side effects.
2. ctx.run() journal entries capture the lambda's return value, not its side effects — if the lambda returns a charge ID and the charge is later refunded or disputed, replay returns the stale charge ID
Restate's journal stores the serialized return value of each ctx.run() lambda. On replay, the stored value is returned to the handler without re-executing the lambda. If your billing handler journals a Stripe charge ID and then the charge is refunded or disputed between the initial run and a later replay (for example, a handler that processes the same customer monthly and replays older billing context on a restart), the replayed handler receives the stale charge ID and may proceed as if the charge is valid. For billing workflows that reference historical charge IDs in downstream processing, implement a check against the Stripe API (also wrapped in ctx.run()) to verify the current charge status before making decisions based on a journaled charge ID.
3. Restate Virtual Object concurrency key is the object key, not the billing period — concurrent calls to the same virtual object handler queue, but calls to different object keys run in parallel without cross-key coordination
Restate's Virtual Object provides a per-key concurrency guarantee: only one handler invocation per object key runs at a time. For a CustomerBilling virtual object keyed on customer_id, concurrent calls to bill_for_period for the same customer queue behind each other — preventing double-billing for a single customer from concurrent fan-out. But calls for different customers run fully in parallel with no cross-customer coordination. If the orchestrator dispatches fan-out calls with a data error that affects all customers, all concurrent handlers charge the wrong amount simultaneously. Per-customer vault keys (one key per CustomerBilling virtual object key) remain necessary because the Restate concurrency model only protects per-object state consistency, not cross-object dollar-cap enforcement.
4. Restate's ctx.sleep() and durable timers fire the handler continuation at approximately the scheduled time, but clock skew or Restate cluster restarts can fire the timer slightly early or late — billing handlers triggered by timers should include a billing-period guard
Restate implements durable timers via await ctx.sleep(duration) or scheduled invocations. A monthly billing workflow that sleeps until the first day of the next month will resume when the timer fires — but the exact wall-clock time at resumption depends on the Restate cluster's timer resolution and any cluster restart latency. A timer scheduled for 2026-08-01 00:00:00 UTC might fire at 2026-07-31 23:59:58 UTC (slightly early due to timer imprecision) or 2026-08-01 00:00:05 UTC (slightly late due to cluster restart). If the handler does not validate that the current billing period matches the intended period before charging, a timer that fires slightly early charges customers for August in July. Always include a billing-period guard at the start of any timer-triggered billing handler: check that the current date is within the expected billing window before proceeding to the charge step.
FAQ
Does Restate's exactly-once execution guarantee mean we don't need Stripe idempotency keys?
No. Restate's exactly-once guarantee applies to journaled side effects — once a ctx.run() lambda returns successfully and its result is committed to the journal, that result is returned on every subsequent replay without re-executing the lambda. But if the lambda raises an exception (including network errors on the Stripe call), the result is not journaled, and Restate retries the lambda. Without an idempotency key, each retry that reaches Stripe creates a new charge. Restate's exactly-once semantics and Stripe idempotency keys operate at different layers: Restate handles replay deduplication for successful journal entries; idempotency keys handle deduplication for the Stripe API call itself during the window where the lambda is executing but hasn't committed to the journal yet.
Should we use the Restate invocation ID as the Stripe idempotency key?
No. Restate generates a unique invocation ID for each handler invocation. If an invocation fails after the Stripe call but before completing, and the caller retries by submitting a new invocation (rather than Restate's internal retry of the same invocation), the new invocation has a new ID. Using the invocation ID as the idempotency key means the second invocation creates a new charge. Use content-hash idempotency keys derived from the billing event's immutable business fields (customer ID, amount, billing period) — these are stable across all retry paths, whether the retry comes from Restate's internal ctx.run() retry, a new invocation from the caller, or a handler replay after a cluster restart.
How long does Stripe retain an idempotency key result?
Stripe retains idempotency key results for 24 hours from first use. Within 24 hours, submitting the same key returns the original response object with no new charge. After 24 hours, the same key can create a new charge. For Restate handlers that replay within seconds or minutes of the original invocation, 24 hours is more than sufficient. For billing workflows that use Restate's journal for long-term state (handlers that may replay months later to process new events), pair idempotency keys with a VirtualObject state pre-flight check that persists the billed-period record indefinitely in Restate's state store.
Can we use the VirtualObject state pre-flight check instead of idempotency keys?
The pre-flight check and idempotency keys protect against different failure scenarios and work best together. The VirtualObject pre-flight check (reading the billed_periods state at the start of the handler) prevents re-entry when the handler replays after the state has been committed — but the state is committed after the Stripe call succeeds. There is a window between the Stripe call completing and the state being updated (ctx.set()) where a replay can still re-run the Stripe call. Idempotency keys close this window at the Stripe API layer. Use both: the pre-flight check as a guard for the common case and a Stripe idempotency key as the safety net for the race condition window.
What happens if the Keybrake proxy is unreachable when a billing handler executes?
The _charge lambda inside ctx.run() receives a connection error from the proxy. The lambda raises the exception, the ctx.run() block is not committed to the journal, and Restate retries the lambda with exponential backoff. When the proxy recovers, the retry reaches the proxy and the Stripe call proceeds with the same idempotency key. If the first attempt reached Stripe before the proxy became unreachable, Stripe returns the original charge object. If the first attempt never reached Stripe, the retry creates the charge for the first time. In both cases, the customer is charged exactly once. Do not catch proxy connection errors inside the lambda — let them propagate so Restate's retry mechanism handles the recovery.
Does Restate's fan-out guarantee that all child handlers complete before the parent continues?
When the parent handler uses asyncio.gather() with ctx.service_call() handles, yes — asyncio.gather() awaits all results before the parent proceeds. If any child handler raises an unhandled exception, asyncio.gather() propagates the exception to the parent (unless return_exceptions=True is set). For billing fan-out, use return_exceptions=True to collect individual handler results — including errors — rather than failing the entire parent handler if one customer's charge fails. This lets the orchestrator report per-customer outcomes (charged, card declined, vault cap exceeded) rather than treating a single card decline as a fatal orchestration failure.
Protect your Restate billing handlers
Issue per-handler vault keys before your fan-out dispatch. Cap every ctx.run() billing call to the expected charge amount. Get per-invocation audit logs for every charge attempt — including ctx.run() retries.
Related: Temporal Stripe Integration · Prefect Stripe Integration · Dagster Stripe Integration · Trigger.dev Stripe Integration · Hatchet Stripe Integration