Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

Agent payment retries

Put a refund behind an action, give every attempt a key derived from the order, tell the model which of three outcomes it got, and settle an unknown outcome without sending the refund twice.

An agent asks for a refund. Stripe issues it. The response is lost. The agent retries. This guide takes that sequence step by step and shows what each party does, so the customer is refunded once.

The mechanism is explained on Idempotency and retries and When the outcome is unknown. This page is the order to do things in.

1. Define the refund as an action

Two keys are in play. An operator key defines the action and stores the credential. An agent key invokes. The model never sees the Stripe key.

import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.secrets.put('STRIPE_KEY', {
  value: process.env.STRIPE_SECRET_KEY!,
  allowedHosts: ['api.stripe.com'],
  allowedPlacements: ['header'],
});

await ops.actions.create({
  name: 'refund',
  adapter: {
    type: 'stripe.refund',
    config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd' },
    credentialSecret: 'STRIPE_KEY',
  },
});

await ops.impactLimits.create({
  name: 'Refunds per day',
  dimension: 'money',
  currency: 'usd',
  period: 'day',
  amount: 50_000, // $500.00, in minor units
});
import os
from anlyon import Client

ops = Client(api_key=os.environ["ANLYON_OPERATOR_KEY"])

ops.secrets.put(
    "STRIPE_KEY",
    value=os.environ["STRIPE_SECRET_KEY"],
    allowed_hosts=["api.stripe.com"],
    allowed_placements=["header"],
)

ops.actions.create(
    name="refund",
    adapter={
        "type": "stripe.refund",
        "config": {"account": "acct_1Nxyz", "mode": "test", "currency": "usd"},
        "credentialSecret": "STRIPE_KEY",
    },
)

ops.impact_limits.create(
    name="Refunds per day", dimension="money", currency="usd", period="day", amount=50_000
)

The adapter sends Stripe an idempotency key per effect and tags every refund with anlyon_effect metadata. Both exist so a lost response can be recovered later. The limit reserves the refund amount before dispatch, which is what keeps a held unknown from being spent twice.

2. Invoke with a key derived from the order

The key identifies the operation, not the attempt. refund:ord_4417 is right. A timestamp or a random id defeats it.

const agent = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });

const { data } = await agent.actions.invoke(
  'refund',
  { paymentIntent: 'pi_3Pabc', amount: 12000, currency: 'usd' },
  { idempotencyKey: `refund:${orderId}` },
);

console.log(data!.status, data!.grade, data!.effectId);
agent = Client(api_key=os.environ["ANLYON_AGENT_KEY"])

result = agent.actions.invoke(
    "refund",
    {"paymentIntent": "pi_3Pabc", "amount": 12000, "currency": "usd"},
    idempotency_key=f"refund:{order_id}",
)

print(result.data["status"], result.data["grade"], result.data["effectId"])
curl -s -X POST https://api.anlyon.com/api/v2/actions/refund/invoke \
  -H "Authorization: Bearer $ANLYON_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: refund:ord_4417" \
  -d '{ "input": { "paymentIntent": "pi_3Pabc", "amount": 12000, "currency": "usd" } }'

From @anlyonhq/sdk 2.2.0 and anlyon 0.5.0 the SDK generates a key when you pass none. A generated key covers one call to invoke. When your own code can call invoke twice for one order, pass your own.

What a repeated key does, any time later:

Situation What happens
Same key, same request Answered from the invocation as it is now, with X-Idempotent-Replay: current-state. Nothing reaches Stripe.
Same key, different request 409 idempotency_key_reuse. A model that retries with the amount written differently lands here, and nothing is sent.
The invocation is failed or unknown The retry shows that outcome. The key is not freed. Running again is a new decision, see step 6.

3. Tell the model which outcome it got

Three outcomes lead to three different next steps. A model told only “error” picks the wrong one. Return the status in words it cannot misread.

function describe(data: { status: string; grade: string; effectId?: string; error?: string }) {
  switch (data.status) {
    case 'succeeded':
      return `Refund issued. Receipt grade: ${data.grade}.`;
    case 'failed':
      return `Stripe refused the refund. Fix the cause before trying again. ${data.error ?? ''}`;
    case 'unknown':
      return `Refund outcome unknown: Stripe may have applied it. Do not retry. Effect ${data.effectId} is being reconciled.`;
    default:
      return `Refund is ${data.status}.`;
  }
}
def describe(data: dict) -> str:
    status = data["status"]
    if status == "succeeded":
        return f"Refund issued. Receipt grade: {data['grade']}."
    if status == "failed":
        return f"Stripe refused the refund. Fix the cause before trying again. {data.get('error', '')}"
    if status == "unknown":
        return (
            "Refund outcome unknown: Stripe may have applied it. Do not retry. "
            f"Effect {data.get('effectId')} is being reconciled."
        )
    return f"Refund is {status}."

The outcomes, exactly:

Outcome When What the agent should do
succeeded Stripe answered 2xx. Read the receipt.
failed The request provably never left, or Stripe answered 4xx. A 402 is a declined card. Fix the cause. Retry on purpose, step 6.
unknown Timeout, reset after the request was written, 5xx, or a 409 from Stripe. Stop. Keep the effectId. A person or the reconciler settles it.

The invocation’s error on an unknown outcome already says this: The provider may have applied effect <id>; Anlyon is reconciling it. Do not retry; inspect the effect. From the CLI, an invoke whose outcome is unknown exits 13.

4. Wait, or reconcile now

On unknown, Anlyon has already scheduled a read-back. It asks Stripe for the refund by id, or by the anlyon_effect metadata. It never writes. A match settles the effect succeeded with grade confirmed, and the limit moves the amount from reserved to consumed.

You can run the same lookup now, with a key holding actions:invoke:

const { data: effect } = await ops.actions.reconcileEffect(effectId);
console.log(effect!.outcome, effect!.grade, effect!.verification);
effect = ops.actions.reconcile_effect(effect_id).data
print(effect["outcome"], effect["grade"], effect["verification"])
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/$EFFECT_ID/reconcile \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"

Reconcile works while the environment is halted. An effect that is already settled answers 409.

A lookup that finds nothing leaves the effect unknown. Absence of evidence is not failure: metadata can be removed and a list endpoint can lag. After dispatch, only Stripe’s own record of the refund in a failed state settles the effect failed without a person.

5. Settle it: replay or resolve

Two ways remain. Both need actions:resolve, which no key receives by default, and the key that requested the effect can do neither.

Replay sends the same refund under the same Stripe idempotency key. Stripe deduplicates, so inside 24 hours a replay either returns the original refund or creates the one that never landed. It is a write, and it passes every control the original passed.

await ops.actions.replayEffect(effectId);
ops.actions.replay_effect(effect_id)
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/$EFFECT_ID/replay \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"

After 24 hours the adapter no longer treats the key as usable and a repeat could create a second refund. Then a person checks the Stripe dashboard and records what they found:

await ops.actions.addEffectNote(effectId, 'Checked Stripe dashboard for pi_3Pabc.');
await ops.actions.resolveEffect(effectId, {
  outcome: 'succeeded',
  reason: 'Refund re_3Pq is visible on pi_3Pabc, created at 14:02 UTC.',
});
ops.actions.add_effect_note(effect_id, "Checked Stripe dashboard for pi_3Pabc.")
ops.actions.resolve_effect(
    effect_id,
    outcome="succeeded",
    reason="Refund re_3Pq is visible on pi_3Pabc, created at 14:02 UTC.",
)
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/$EFFECT_ID/resolve \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "succeeded", "reason": "Refund re_3Pq is visible on pi_3Pabc, created at 14:02 UTC." }'

A manual succeeded grades acknowledged, never confirmed, and keeps verification: "manual". A manual failed releases the held amount. The reason must be at least 10 characters and stays on the record with who wrote it and when.

6. Retry on purpose

A failed invocation can be run again. It is a new decision, so it needs a new key and names the original:

await ops.actions.invoke(
  'refund',
  { paymentIntent: 'pi_3Pabc', amount: 12000, currency: 'usd' },
  { retryOf: invocationId, idempotencyKey: `refund:${orderId}:retry-1` },
);
ops.actions.invoke(
    "refund",
    {"paymentIntent": "pi_3Pabc", "amount": 12000, "currency": "usd"},
    retry_of=invocation_id,
    idempotency_key=f"refund:{order_id}:retry-1",
)

The original must be failed, on its own or resolved as failed, and of the same action. An unknown original must be resolved first. A succeeded one cannot be retried this way. Each invocation can be retried once. The link shows on both as retryOf and retriedBy.

7. Rehearse the lost response

Stripe cannot be made to lose a response on demand, so the adapter can. On a separate test-mode action, set faultDrill: "drop_response_after_send". Anlyon sends the refund, discards Stripe’s answer, records unknown, holds the amount, and settles it from Stripe’s record on the normal schedule. The first lookup is about 30 seconds later.

await ops.actions.create({
  name: 'refund-drill',
  adapter: {
    type: 'stripe.refund',
    config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd', faultDrill: 'drop_response_after_send' },
    credentialSecret: 'STRIPE_KEY',
  },
});
ops.actions.create(
    name="refund-drill",
    adapter={
        "type": "stripe.refund",
        "config": {
            "account": "acct_1Nxyz",
            "mode": "test",
            "currency": "usd",
            "faultDrill": "drop_response_after_send",
        },
        "credentialSecret": "STRIPE_KEY",
    },
)

Every preview of a drilled action says “Fault drill”. Keep drills on their own action: every invocation of a drilled action loses its response.

When the provider is not Stripe

The same steps apply to any API through a declared action, with two differences.

  • No provider key is sent, so there is no replay. A replay answers 409 REPLAY_REFUSED with reason replay_unsupported before anything is sent. Step 5 is resolve only.
  • The read-back is the verify rule you declared. Reconciliation repeats that GET and compares it with the declared status and checks. A match confirms the write. No match leaves it unknown. Write a rule that distinguishes this write from a resource that already existed. An action with no verify rule, or one whose rule needs the lost response, stays unknown until a person resolves it.

github.file_update sends the reviewed blob SHA as a precondition instead of a key, and offers no replay. Its read-back looks for the commit carrying the effect trailer.

How it behaves

  • A governed write is sent once. A dispatch is claimed once, and the database refuses a second dispatch for one effect.
  • A lost response is unknown, not failed. Timeout, reset after the write, 5xx, and a 409 from Stripe all land there.
  • The amount stays held while unknown. It is neither consumed nor released, and a dependent effect does not start.
  • Reconciliation reads, never writes. Not finding the refund leaves it unknown.
  • Replay is offered for stripe.refund and resend.email_send, inside 24 hours. A declared action and github.file_update do not offer it.
  • Only Stripe’s own record of a failed refund settles failed without a person. Failing to find it or failing to replay it never does.
  • The key that requested an effect cannot reconcile it to failed, replay it or resolve it. Settling needs actions:resolve, which no key gets by default.
  • A same-key retry never reaches Stripe. It is answered from the recorded invocation. Running again needs a new key and retryOf, and only from failed.

Where to go next

Was this page helpful?