Skip to content

timeoutMs is ignored whenever the request also passes an AbortSignal #1123

Description

@daveycodez

Summary

timeoutMs, whether set on the client or per request, stops applying as soon as the request also carries an AbortSignal. The timeout isn't composed with the signal; it's skipped entirely.

Where

Installed @openrouter/sdk 0.13.20, esm/lib/sdks.js L125:

if (!fetchOptions?.signal && conf.timeoutMs && conf.timeoutMs > 0) {
    const timeoutSignal = AbortSignal.timeout(conf.timeoutMs);
    fetchOptions.signal = timeoutSignal;
}

The same guard is still on main (64d6db7) in src/lib/sdks.ts L230:

if (!fetchOptions?.signal && conf.timeoutMs != null && conf.timeoutMs > 0) {
  context.timeoutMs = conf.timeoutMs;
}

The timeoutMs doc comment says that fetchOptions.signal "will take precedence over this option", so this is the documented behaviour. The problem is that "precedence" here means the timeout is dropped altogether.

Why it matters

Framework adapters forward their own cancellation signal on every request. For example, @tanstack/ai-openrouter passes { signal } to chat.send / responses.send whenever its caller cancels, which is typical for server code that aborts on client disconnect. Anyone using the SDK through such an adapter can't use timeoutMs, because a hung request is bounded only by whatever eventually fires that signal.

Expected

When both are given, compose them. The request aborts on whichever fires first, and a timeout surfaces as a timeout (RequestTimeoutError), distinguishable from a caller abort (RequestAbortedError).

On main most of this already exists: _do builds a per-attempt AbortSignal.timeout(timeoutMs) and runs it through combineSignals(cloned.signal, timeoutSignal) (L284-L289). So dropping the !fetchOptions?.signal condition at L230 looks like enough to get "first to fire wins". The abort reason of AbortSignal.timeout is a TimeoutError DOMException, which the existing isTimeoutError branch already maps to RequestTimeoutError. (If this file is generator-owned, the change may belong in the Speakeasy template or a persistent edit.)

For comparison, #773 already merges the caller signal with replicated timeoutMs behaviour inside callModel. This request would give chat.send, responses.send and the other raw methods the same guarantee.

Repro

const client = new OpenRouter({ apiKey, timeoutMs: 5_000 })
const controller = new AbortController() // never aborted

// Against an endpoint that accepts the connection but never responds,
// this waits indefinitely instead of failing after ~5s.
await client.chat.send({ chatRequest }, { signal: controller.signal })

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions