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
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 · ◷
Rule
require-fetch-try-catch(eslint-factory/src/rules/require-fetch-try-catch.ts)Summary
isInsideTryBlockstops the ancestor walk at any function boundary (FunctionDeclaration | FunctionExpression | ArrowFunctionExpression, lines 61–82). The inline rationale is: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 itselfawaited inside an enclosingtry: the rejectedfetchpromise then propagates up the awaited chain and is caught by the outercatch. The rule flags these safe sites as errors.Grounded false positive
actions/setup/js/check_version_updates.cjs:92:withRetry(actions/setup/js/error_recovery.cjs:207) doesconst result = await operation();(line 222) and re-throws the enhanced error on a non-retryable / exhausted failure (lines 236, 250, ...). So afetchnetworkTypeErrorinside the callback rejects the callback promise, rejectswithRetry's promise, and surfaces atawait withRetry(...)on line 90 — which is inside thetryon line 89. The site is fully protected, yet the rule reportsrequireTryCatch.This is the only live
await fetchinside an awaited callback in the corpus, so it is exactly one spurious warning today — but theawait 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–150must stay invalid — none of them await the callback in place:try { async function later() { await fetch(url); } setTimeout(later, 0); } catch {}—lateris 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
falseat a crossed function boundary inisInsideTryBlock, recognize the awaited-callback pattern: if the enclosing function node is an inline argument (FunctionExpression/ArrowFunctionExpression) of aCallExpression, and thatCallExpressionis the argument of anAwaitExpressionthat is itself inside atryblock (withhandler != null) in the same function scope, treat theawait fetchas protected. Keep the conservative default (flag) for callbacks that are not immediately awaited (stored,setTimeout/.thenfire-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.require-fetch-try-catch.test.ts:128–150remain invalid (fire-and-forget / stored-but-uninvoked).check_version_updates.cjs:92is no longer flagged after the fix.isInsideTryBlockto 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.