paypal / paypal/agent-toolkit

PayPal-Request-Id is set per client, not per call — and nothing ever sets it, so create_order and capture_order have no idempotency (client.ts:122)

Open
#98 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
194
Forks
114
Avg merge
3d 13h
Merged PRs (30d)
1

Description

Summary

PayPal-Request-Id — PayPal's idempotency header — is populated from a client-level configuration field, not from anything per-call:

// typescript/src/shared/client.ts:113
async getHeaders(): Promise<Record<string, string>> {
  ...
  if (this._context.request_id) {
    headers['PayPal-Request-Id'] = this._context.request_id;   // one value, every request
  }
  ...
}

request_id is declared in Configuration (shared/configuration.ts:5, and shared/configuration.py:14) and is never assigned anywhere in the toolkit — the only references in the codebase are the declaration and this read. getHeaders() is the single header path for every call, create_order and capture_order included.

That shape leaves an integrator with two options and no correct one:

Left at its default (unset). No PayPal-Request-Id is sent on any request. create_order and capture_order have no idempotency at all. An agent framework that retries a timed-out tool call — the default behaviour in every framework this toolkit targets — produces a second order, or a second capture on the same order.

Set, as the configuration API invites. The same value is attached to every request for the lifetime of the client. Distinct, legitimate orders then collide on one idempotency key, and PayPal answers the second call with the first call's result. That is worse than the default, because it is silent and returns a plausible wrong answer rather than an error.

There is no third path. The toolkit exposes no way to vary request_id per call, so an integrator cannot express "this order is one purchase, retry it safely."

What I verified

  • request_id appears exactly four times across both SDKs: declared in the TS Configuration interface, declared and stored in the Python Configuration.__init__, and read once in client.ts:122. Nothing writes it.
  • getHeaders() is called from every request site in shared/functions.ts (lines 70, 96, 122, 156, 182, …), so this applies uniformly, not to one endpoint.
  • createOrder (functions.ts:1007) and captureOrder route through the same client, and capture_order is dispatched in api.ts:178.
  • No tool description or parameter schema mentions idempotency, retry safety, or reusing an identifier.

Why this one is worth more than the usual

Capture is the sharp end. A retried capture_order against an already-captured order is the case PayPal's own PayPal-Request-Id exists to make safe, and it is precisely the call an agent is most likely to repeat after a dropped connection — the money has moved, the reply did not arrive, and nothing in the response tells the caller which happened.

The ceiling on this report

This is a code read. I have not run the toolkit against PayPal's sandbox and observed duplicate orders, and I would want to before claiming a rate rather than a mechanism. What I can show is that the header is per-client by construction, that nothing sets it, and that no per-call path exists.

If PayPal's API deduplicates create_order/capture_order server-side independently of PayPal-Request-Id, this reduces to a documentation gap — and I would say so publicly. I could not determine that from outside.

Direction of a fix

The shape of the fix matters more than the wording here, so briefly:

  1. Accept request_id per call, not per client — an optional parameter on the order and capture tools that flows into getHeaders() for that request only.
  2. Derive a default from the call's own content when the caller does not supply one (for capture_order, the order id is a natural stable key), so that a retry of the same logical operation carries the same header without the caller having to think about it.
  3. Say it in the tool description: that the value must be stable across retries of the same purchase and distinct between different purchases. Both halves matter — the current API makes it impossible to satisfy them simultaneously.
  4. If per-client request_id has a legitimate use, keep it, but document that it must not be set when the client serves more than one order.

For context on why I look at this seam: I read money paths for the failure where an outcome that could not be determined becomes a second charge. Ten paths over three days; seven could double-charge. The public record with evidence on each row is at https://aurumflux.co/retry-safety/ — nobody is named there while their finding is open, and teams that fix are listed with credit and their time-to-fix.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with typescript/src/shared/client.ts and the Configuration declarations in shared/configuration.ts, then trace getHeaders() callers in shared/functions.ts and the createOrder, captureOrder, and capture_order entry points. The issue provides no test file to run; done should establish and verify a per-call, retry-stable idempotency path and document its use in the relevant tool descriptions.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, typescript
Domain
api, backend-api-design, payments
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.