Skip to content

fix(ai): retry middleware honors abortSignal and skips AbortError - #5342

Closed
serhiizghama wants to merge 2 commits into
genkit-ai:mainfrom
serhiizghama:fix/retry-middleware-abort-signal
Closed

serhiizghama wants to merge 2 commits into
genkit-ai:mainfrom
serhiizghama:fix/retry-middleware-abort-signal

Conversation

@serhiizghama

Copy link
Copy Markdown

Problem

The `retry` model middleware retries any non-`GenkitError`, including `AbortError`. When a caller passes `abortSignal` to `generateStream`/`generate` and aborts mid-stream, the middleware intercepts the resulting `AbortError` and sleeps through exponential backoff before retrying the model call — completely defeating the abort. The backoff sleep itself is also not signal-aware, so even if a later attempt detects the abort, the user still waits out the full delay.

Fixes #5331.

Solution

  • Convert `retry` to a `ModelMiddlewareWithOptions` so it can read `opts.abortSignal`.
  • Short-circuit at three points:
    • Before every attempt if the signal is already aborted.
    • On any caught `AbortError` (matched by `error.name === 'AbortError'` or `error.code === 'ABORT_ERR'`).
    • During the backoff sleep — `abortableDelay` clears the timer and rejects with `signal.reason` when the signal fires.
  • Existing `__setTimeout` hook is preserved so the test helper (`TEST_ONLY.setRetryTimeout`) still works.

The retry function's public return type changes from `ModelMiddleware` to `ModelMiddlewareWithOptions`. Both are valid `ModelMiddlewareArgument` entries, so usage via `use: [retry()]` is unaffected.

Testing

Three new cases in `tests/model/middleware_test.ts`:

  • `should not retry on AbortError` — model throws an `AbortError`; verifies the model is called exactly once and no backoff sleep runs.
  • `should not retry once abortSignal is already aborted` — pre-aborted signal; verifies retry bails before sleeping.
  • `should abort the backoff sleep when signal fires mid-retry` — uses the real `setTimeout` so the abort listener fires; verifies the 10s sleep is interrupted and the second attempt is skipped.

All 38 retry/fallback/system-prompt/context tests in the suite pass:

```
pnpm test:single tests/model/middleware_test.ts

tests 38 / pass 38 / fail 0

```

`pnpm check` (tsc) is clean.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the retry middleware to support AbortSignal, enabling the cancellation of both active requests and backoff delays. It introduces an abortableDelay helper and an isAbortError check, along with tests covering various abort scenarios. Feedback was provided regarding a potential ReferenceError in the abortableDelay implementation if the timeout executes synchronously, recommending that the abort listener be defined before the timer starts to ensure robustness.

Comment on lines +405 to +422
return new Promise((resolve, reject) => {
const timer = __setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal?.reason ?? new Error('Aborted'));
};
if (signal) {
if (signal.aborted) {
clearTimeout(timer);
reject(signal.reason ?? new Error('Aborted'));
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If __setTimeout is synchronous (as used in some internal tests), onAbort is accessed before it is initialized, leading to a ReferenceError. Reordering the logic to define onAbort and add the listener before starting the timer ensures the code is robust against synchronous execution.

  return new Promise((resolve, reject) => {
    let timer: NodeJS.Timeout | undefined;
    const onAbort = () => {
      if (timer !== undefined) clearTimeout(timer);
      reject(signal?.reason ?? new Error('Aborted'));
    };
    signal?.addEventListener('abort', onAbort, { once: true });
    timer = __setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);
  });
References
  1. Avoid making changes for minor optimizations if the existing code is functionally correct.

@pavelgj

pavelgj commented May 19, 2026

Copy link
Copy Markdown
Member

I'm fixing this as part of a bigger change to the retry middleware (in the new @genkit-ai/middleware package) -- #5343

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

retry middleware silently retries AbortError, ignoring abortSignal passed to generateStream

2 participants