Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

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

Đang mở
#294 3 bình luận 0 reaction 0 người được giao Xem trên GitHub

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
Lĩnh vực
api, payments, security

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:

  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.

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

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của stripe/link-cli

Tất cả issue của stripe/link-cli

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.