apiFetch re-sends a POST after a 401 and no request carries an idempotency key — a spend-request create can be applied twice
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 5/5
- Thời gian dự kiến
- Hơn một tuần
- Mức phù hợp với người mới
- 35/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Cần làm rõ
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- typescript
Hướng nghiên cứu
Bắt đầu với packages/sdk/src/resources/base.ts:109, sau đó đọc resources/spend-request.ts:101 và errors.ts:41; grep các tham chiếu đến idempotency trong packages/. Xác nhận backend có loại bỏ trùng lặp đối với POST /spend_requests và create_delegated hay không, sau đó chọn một trong bốn phương án được đề xuất. Hoàn tất nghĩa là hành vi retry và caller-retry được xác định rõ là an toàn hoặc được ghi lại, với request identity liên quan và failure outcome được định nghĩa.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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.
- Ngôn ngữ chính
- TypeScript
- Star
- 873
- Fork
- 147
- Merge trung bình
- 23 giờ 53 phút
- Pull request đã merge (30 ngày)
- 76
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của stripe/link-cli
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 72/100
Maintainer thường phản hồi trong vòng 1 ngày
-
`balances list` renders non-USD balances with a `$` symbol and a fixed `/100`Có thể đã có người làm @RaphaelFakhri đã nhận 2 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 25/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 42/100
stripe/link-cli#479 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 1/100
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của stripe/link-cli
Issue tương tự
-
e2e-failure ready-to-code
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 79/100
redhat-developer/rhdh-plugin-export-overlays#4580 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
devdotfast/whiteboard#1415 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
area/sessions comp/desktop desktop/sessions P2 type/bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
NousResearch/hermes-agent#145383 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
Maintainer thường phản hồi trong vòng 1 ngày
-
enhancement p2 setup
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
vercel/eve#5419 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày