Skip to content

require-fetch-try-catch: false positive on await fetch inside an async callback that is itself awaited within an enclosing try [Content truncated due to length] #47931

Description

@github-actions

Rule

require-fetch-try-catch (eslint-factory/src/rules/require-fetch-try-catch.ts)

Summary

isInsideTryBlock stops the ancestor walk at any function boundary (FunctionDeclaration | FunctionExpression | ArrowFunctionExpression, lines 61–82). The inline rationale is:

A try/catch outside the current async function cannot catch a rejected promise from an await inside a nested function.

That reasoning holds only for fire-and-forget callbacks (e.g. setTimeout(cb, 0)) or callbacks that are merely stored. It is incorrect when the enclosing function is an inline callback passed directly to a call that is itself awaited inside an enclosing try: the rejected fetch promise then propagates up the awaited chain and is caught by the outer catch. The rule flags these safe sites as errors.

Grounded false positive

actions/setup/js/check_version_updates.cjs:92:

// check_version_updates.cjs
try {                                   // line 89
  config = await withRetry(             // line 90 — await is INSIDE the try
    async () => {
      const res = await fetch(CONFIG_URL);   // line 92 — FLAGGED, but protected
      if (!res.ok) { /* ... */ }
      return /* ... */;
    },
    { shouldRetry: /* ... */ }
  );
} catch (err) { /* ... */ }             // catches the fetch TypeError

withRetry (actions/setup/js/error_recovery.cjs:207) does const result = await operation(); (line 222) and re-throws the enhanced error on a non-retryable / exhausted failure (lines 236, 250, ...). So a fetch network TypeError inside the callback rejects the callback promise, rejects withRetry's promise, and surfaces at await withRetry(...) on line 90 — which is inside the try on line 89. The site is fully protected, yet the rule reports requireTryCatch.

This is the only live await fetch inside an awaited callback in the corpus, so it is exactly one spurious warning today — but the await wrapper(async () => { await fetch(...) }) retry/wrapper shape is idiomatic and will recur. A false positive here pressures authors to add a redundant inner try/catch or to disable the rule.

Distinction from existing intentional behavior (keep these flagged)

The existing invalid tests at require-fetch-try-catch.test.ts:128–150 must stay invalid — none of them await the callback in place:

  • try { async function later() { await fetch(url); } setTimeout(later, 0); } catch {}later is fire-and-forget; outer try genuinely cannot catch. Still invalid.
  • try { const later = async () => { await fetch(url); }; } catch {} — stored, never invoked in scope. Still invalid.
  • try { const later = async function() { await fetch(url); }; } catch {} — same. Still invalid.

Only the directly-awaited inline callback argument pattern should become valid.

Proposed refinement

Before returning false at a crossed function boundary in isInsideTryBlock, recognize the awaited-callback pattern: if the enclosing function node is an inline argument (FunctionExpression/ArrowFunctionExpression) of a CallExpression, and that CallExpression is the argument of an AwaitExpression that is itself inside a try block (with handler != null) in the same function scope, treat the await fetch as protected. Keep the conservative default (flag) for callbacks that are not immediately awaited (stored, setTimeout/.then fire-and-forget, event handlers).

Acceptance criteria

  • try { config = await withRetry(async () => { const r = await fetch(u); return r; }); } catch (e) {} is valid (add as a test).
  • try { await Promise.all([fetch(a), fetch(b)]); } catch (e) {}-style directly-awaited callback wrappers are considered.
  • All three existing invalid cases at require-fetch-try-catch.test.ts:128–150 remain invalid (fire-and-forget / stored-but-uninvoked).
  • Re-verify check_version_updates.cjs:92 is no longer flagged after the fix.
  • Correct the inline rationale comment in isInsideTryBlock to note it applies to non-awaited (fire-and-forget) callbacks.

Grounded via static review (npm/lint firewalled in this environment): check_version_updates.cjs:89–102, error_recovery.cjs:207–252.

Generated by 🤖 ESLint Refiner · age00 · 317.3 AIC · ⌖ 13 AIC · ⊞ 4.6K ·

  • expires on Jul 31, 2026, 10:13 PM UTC-08:00

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions