Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

オープン
#294 コメント 3 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
5/5
見積もり時間
1週間以上
初心者へのやさしさ
35/100
issue の種類
バグ
明瞭さ
説明が足りない
活発さ
活発
技術スタック
typescript
領域
api, payments, security

調査の方向性

packages/sdk/src/resources/base.ts:109 から始め、次に resources/spend-request.ts:101 と errors.ts:41 を読み、packages/ 内で idempotency の参照を grep します。backend が POST /spend_requests と create_delegated を重複排除するか確認し、その後、提案された4つの解決策のいずれかを選択します。完了とは、retry と caller-retry の動作が明示的に安全であるか文書化され、関連する request identity と failure outcome が定義されていることを意味します。

索引モデルが issue の本文から書いたものです。

説明

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.

主要言語
TypeScript
スター
842
フォーク
136
平均マージ
1日 6時間
マージ済み PR(30日)
88

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

stripe/link-cli のほかの issue

stripe/link-cli の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。