Skip to content

apiFetch re-sends a POST after a 401 and no request carries an idempotency key — a spend-request create can be applied twice #294

Description

@aurumflux20

Summary

BaseResource.apiFetch automatically re-sends a request after a 401, and no request in the SDK carries an idempotency key — including spendRequests.create, which creates a spend request and, with approve: true, posts to create_delegated. If the retried POST is one that creates a spend request, the second send is indistinguishable from a first one to anything that does not dedupe server-side.

I want to be careful about what this does and does not claim, so the ceiling is stated up front: I have not tested Link's backend, and if it deduplicates these creates server-side then this is a documentation gap rather than a defect. What I can show from the published code is that the SDK provides no client-side idempotency, retries a POST on its own, and gives a caller no way to distinguish "not applied" from "unknown" — so the safety of the money path rests entirely on undocumented server behaviour that an integrator cannot verify.

The retry (packages/sdk/src/resources/base.ts:109)

protected async apiFetch(opts: ApiFetchOptions): Promise<ApiFetchResult> {
  const token = await this.getAccessToken();
  const authedOpts = { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } };

  const res = await this.rawFetch(authedOpts);

  if (res.status === 401 && this.canRefreshAccessToken) {
    const refreshedToken = await this.getAccessToken({ forceRefresh: true });
    authedOpts.headers.Authorization = `Bearer ${refreshedToken}`;
    return this.rawFetch(authedOpts);      // same method, same body, re-sent
  }
  return res;
}

Every resource routes through this, including spendRequests.create (resources/spend-request.ts:101), which is a POST to this.endpoint or to ${this.endpoint}/create_delegated.

A 401 usually means the request was rejected before it did any work, which is why this is a reasonable pattern for reads. The cases where that assumption does not hold are the ones worth naming:

  • a token that expires between the gateway admitting the request and the service replying;
  • an edge or proxy that answers 401 after the origin has already processed the request;
  • any deployment where auth is validated at more than one layer.

In each of those the create may have been applied and the SDK sends it again with no shared identity between the two attempts.

No idempotency key anywhere

grep -ri idempot packages/ returns nothing. spendRequests.create sends Content-Type and Authorization only:

const { status, data, rawBody } = await this.apiFetch({
  method: 'POST',
  url,
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(body),
});

This is the part I would flag regardless of the 401 path, because it also governs the retry the caller performs. rawFetch wraps any network failure in LinkTransportError (errors.ts:41):

try {
  response = await this.fetchImpl(opts.url, init);
} catch (error) {
  throw new LinkTransportError(`Request failed: ${opts.method} ${opts.url}`, { cause: error });
}

A caller that catches this has a dropped connection and no way to know whether the spend request was created. There is no key to re-present and no reference to reconcile against, so the only available action is to call create again — which produces a second, genuinely distinct request that every replay guard will correctly accept, because it is new.

This matters more than usual here because the README's premise is that agents spend on the user's behalf. Agent frameworks retry tool calls on timeouts by default; that is the population most exposed to an unreconcilable create.

Why I am reporting it this way

I read money paths in agent and payment systems, and this specific shape — an outcome that could not be determined, turned into a fresh attempt — is the one I look for. Of ten paths read over three days, seven could produce a duplicate payment under an ambiguous failure. Seven teams have shipped fixes from reports like this one; the public record, including the evidence for each row, is at https://aurumflux.co/retry-safety/ — nobody is named there while a finding is open.

What would resolve it

Any one of these closes the gap, in increasing order of cost:

  1. Document the server-side behaviour. If POST /spend_requests and create_delegated are idempotent on the backend, say so in the README and say what the dedupe key is. That alone makes the retry safe and reviewable, and costs nothing to ship.
  2. Accept and forward an idempotency key. Let create take one and send it as a header, so a caller-level retry and the internal 401 retry both present the same identity. Stripe's own API convention is already Idempotency-Key.
  3. Do not auto-retry money-moving POSTs on 401. Refresh the token and surface the outcome to the caller instead, or restrict the automatic retry to idempotent methods.
  4. Give the transport error a reference. A LinkTransportError carrying whatever identity was sent lets a caller reconcile instead of guessing.

Happy to be told this is already handled server-side — that would be the best outcome and I would say so publicly. If it is useful I can also send the exact request sequence I traced.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions