Skip to content

oauth/xai: postXaiToken retries a request the caller already aborted when abort() carries a custom reason #4047

Closed

Description

@lidge-jun

Where

src/oauth/xai.ts line 97 and the catch branch on line 114, on dev:

function isAbortError(error:unknown):boolean{return error instanceof DOMException&&error.name==="AbortError";}
...
}catch(error){if(isAbortError(error)&&signal?.aborted)throw error;last=error; ... await sleep(...);continue;}

Problem

The early-exit guard depends on the rejection being a DOMException named AbortError. That holds for a bare controller.abort(), but when a caller aborts with a reason, fetch (undici, and Bun follows the same spec) rejects with that reason object as-is:

controller.abort(new Error("user cancel"))
  -> rejection: ctor Error, name "Error", instanceof DOMException false

Measured on Node 24.17. In that case isAbortError returns false, the guard is skipped, and the loop sleeps and retries up to two more times on a signal that is already aborted. AbortSignal.timeout() expiry (name === "TimeoutError") is likewise not treated as terminal, so a 30s token timeout can stretch to ~90s across three attempts.

Fix

Check the signal, not the error's class:

} catch (error) {
  if (signal?.aborted) throw error;                       // any reason
  if ((error as { name?: string })?.name === "TimeoutError") throw error;
  ...
}

Repro

Pass signal from a controller, call controller.abort(new Error("x")) before the first fetch resolves, inject deps.sleep that records calls: sleep is invoked twice instead of zero times.

Found while porting this module into ima2-gen (devlog 260909_grok_native_oauth/001_opencodex_port_spec.md D3).

Activity

  1. added
    bugSomething isn't working
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    on Sep 8, 2026
  2. lidge-jun commented on Sep 8, 2026

    @lidge-jun
    OwnerAuthor

    리뷰 · 우선순위 64 / 80

    이 이슈는 postXaiToken이 이미 abort된 요청을 두 번 더 재시도할 수 있다는 버그입니다. 지금 dev HEAD cfff026 src/oauth/xai.ts를 보면 isAbortError는 DOMException이고 이름이 AbortError일 때만 참입니다(97행). catch 쪽(114행 근처)은 isAbortError(error) && signal?.aborted일 때만 바로 throw하고, 아니면 sleep 후 continue합니다.

    문제는 호출자가 controller.abort(new Error("user cancel"))처럼 reason을 실어 abort하면, undici/Bun fetch는 그 reason 객체를 그대로 reject한다는 점입니다. 그때 instanceof DOMException이 false라 가드가 빠지고, 이미 죽은 signal로 최대 2번 더 sleep+retry합니다. 이슈가 Node 24.17에서 확인했습니다. 같은 이유로 AbortSignal.timeout()의 TimeoutError도 터미널로 안 보고, 30초 타임아웃이 재시도 때문에 ~90초까지 늘어날 수 있습니다. requestSignal이 이미 AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS)와 호출자 signal을 AbortSignal.any로 묶고 있어서, 타임아웃 경로가 실제로 여기로 들어옵니다.

    이건 사용자 취소·타임아웃 UX와 직접 닿습니다. Grok 로그인/리프레시 중 취소했는데 백그라운드가 계속 두드리면 로그·레이트·오류 메시지가 지저분해집니다. #4045/#4046과 같은 모듈이라 한 패치에 넣기 좋고, types/config 분할과도 무관합니다.

    제안 수정은 에러 클래스가 아니라 signal?.aborted와 TimeoutError 이름을 보는 쪽입니다. 그게 fetch 구현 차이를 덜 탑니다.

    라인 97 · isAbortError - reason 있는 abort와 TimeoutError를 놓칩니다. DOMException AbortError만 통과합니다.

    라인 114 · catch 가드 - isAbortError && signal?.aborted 이중 조건 때문에, abort인데 에러 shape이 다르면 재시도 루프로 떨어집니다.

    경로 requestSignal + 3회 루프 - 타임아웃 한 번이 세 번으로 늘어날 수 있어, UI/CLI 대기 상한이 깨집니다.

    메인테이너의 판단이 필요한 지점

    너의 추천
    수락하세요. 세 xAI OAuth 이슈 중 체감 위험은 이쪽이 큽니다. signal?.aborted 우선 가드 + TimeoutError 조기 throw + sleep 호출 횟수 테스트가 최소 세트입니다.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Sep 9, 2026

    @lidge-jun
    OwnerAuthor

    Fixed on dev via #4087 at 4a08c75.

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

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingproviderProvider adapters, OpenAI-compat presets, upstream API quirks

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions