apiFetch re-sends a POST after a 401 and no request carries an idempotency key — a spend-request create can be applied twice
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 35/100
- issue の種類
- バグ
- 明瞭さ
- 説明が足りない
- 活発さ
- 活発
- 技術スタック
- typescript
調査の方向性
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:
- Document the server-side behaviour. If
POST /spend_requestsandcreate_delegatedare 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. - Accept and forward an idempotency key. Let
createtake 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 alreadyIdempotency-Key. - 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.
- Give the transport error a reference. A
LinkTransportErrorcarrying 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
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
stripe/link-cli のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 1 日以内に返信
-
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
メンテナーはふだん 1 日以内に返信
-
Device-flow refresh token rejected immediately: 400 "refresh_token is invalid" on first refreshオープン
難易度 4/5 3〜5日 初心者へのやさしさ 32/100
stripe/link-cli#406 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信
-
bunx hangs after link-cli commands by implicitly starting a development server対応中かも @awesamarth が 35 日前に担当しました。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 55/100
メンテナーはふだん 1 日以内に返信
stripe/link-cli の issue をすべて見る
似ている issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 80/100
aws/agentcore-cli#2606 ·
メンテナーはふだん 1 日以内に返信
-
ai-driven-qa bug Cosmetic
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
メンテナーはふだん 1 日以内に返信
-
ai-driven-qa bug claude Minor
難易度 2/5 1〜3時間 初心者へのやさしさ 64/100
linagora/twake-mail-admin#301 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
メンテナーはふだん 1 日以内に返信
-
reproduced
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
anomalyco/opencode#56038 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信