Skip to content

fix: normalize TypeError to AbortError when fetch is aborted during OPTION request#1549

Open
FreakTheMighty wants to merge 1 commit intoNASA-AMMOS:masterfrom
FreakTheMighty:fix/abort-cors-preflight-typeerror
Open

fix: normalize TypeError to AbortError when fetch is aborted during OPTION request#1549
FreakTheMighty wants to merge 1 commit intoNASA-AMMOS:masterfrom
FreakTheMighty:fix/abort-cors-preflight-typeerror

Conversation

@FreakTheMighty
Copy link
Copy Markdown

fix: normalize TypeError to AbortError when fetch is aborted during CORS preflight

When we updated to the latest version of 3DTilesRendererJS we began noticing missing tiles on some tilesets. We tracked the issue down to how network abort interacts with OPTIONS preflight requests.

Problem

When navigating a large tileset, the LRU cache will eventually fill and begin evicting
tiles. Each tile has an associated AbortController; when the LRU evicts a tile, the
eviction callback fires controller.abort() to cancel any in-flight download for that
tile (L1553).

If that download is mid-CORS-OPTIONS-preflight when the abort fires, Chrome resets the
underlying TCP connection and fetch() rejects with TypeError: Failed to fetch instead
of AbortError. This is a known spec-level race: the preflight runs with its own inner
fetch params, and the abort signal may not propagate into those params before the
preflight returns a plain network error — which the Fetch spec maps to TypeError rather
than AbortError. See whatwg/fetch#443.

The catch handler at
L1770–1776
has two guards to detect a clean abort:

if ( signal.aborted ) {
    return;                      // guard 1: misses the race where abort hasn't settled yet
}

if ( error.name !== 'AbortError' ) {
    // mark tile FAILED ❌      // guard 2: TypeError is not AbortError, so this always fires
}

Both guards fail for the Chrome CORS preflight case: signal.aborted can be false if
the TypeError arrives before the abort has fully propagated, and error.name is
'TypeError' rather than 'AbortError'. The tile is permanently marked FAILED and
will not be retried.

This bug only surfaces in practice when:

  • The tileset serves assets from a different origin (triggering CORS preflights), and
  • The LRU cache fills and begins evicting tiles with in-flight downloads.

With the default 400 MB byte limit this can happen mid-session on large tilesets.

Fix

Normalize the error in the default fetchData implementation. If fetch() rejects with
TypeError and signal.aborted is already true, re-throw as AbortError so the
existing abort-detection logic in the catch chain handles it cleanly:

fetchData( url, options ) {

    const { signal } = options;
    return fetch( url, options ).catch( err => {

        // Chrome resets the TCP connection when an AbortSignal fires mid-CORS-preflight,
        // causing fetch() to reject with TypeError instead of AbortError. Normalize this
        // so that callers only need to handle AbortError.
        if ( err.name === 'TypeError' && signal && signal.aborted ) {
            throw new DOMException( 'Aborted', 'AbortError' );
        }

        throw err;

    } );

}

Catching at the fetchData level (one microtask after the rejection) is the earliest
point where signal.aborted can be checked, before the error travels through the
downstream .then() chain.

References

  • whatwg/fetch#443 — WHATWG confirmation
    that all CORS preflight failures collapse to TypeError
  • L1553 — LRU eviction callback calling controller.abort()
  • L1770–1776 — the catch handler with the two failing guards

…ORS preflight

When an AbortSignal fires while a CORS OPTIONS preflight is in-flight,
browsers (notably Chrome) reset the TCP connection and reject the fetch()
promise with TypeError ("Failed to fetch") instead of AbortError. This
occurs because the preflight runs with its own inner fetch params, and
the abort signal may not propagate to those inner params before the
preflight returns a plain network error — which the Fetch spec maps to
TypeError rather than AbortError.

The result is that the abort-detection logic in the tile loading catch
chain (which checks both signal.aborted and error.name === 'AbortError')
can fail to recognize the cancel as clean, permanently marking the tile
as FAILED.

Fix this in the default fetchData implementation by catching TypeError
immediately after the fetch() call — the earliest microtask hop — and
re-throwing as AbortError when signal.aborted is true.

Ref: whatwg/fetch#443
@gkjohnson
Copy link
Copy Markdown
Contributor

Hello! Thanks for the report and the fix but this seems like something that should be addressed by browsers and whatwg/fetch#443 only outlines why potentially sensitive preflight server-response information (like error codes) should be hidden. Not client-side evaluated cancellations. Is there an issue discussing the behavior for abort specifically in this case?

@FreakTheMighty
Copy link
Copy Markdown
Author

Thanks for reviewing this. I can dig in a bit more, but my understanding from this ticket is that getting the TypeError back from a failed preflight, in this case due to an abort, is expected behavior.

@gkjohnson
Copy link
Copy Markdown
Contributor

From whatwg/fetch#443:

we cannot expose such details as they would reveal aspects the same-origin policy is "designed" to protect.

The issue explains why they can't return more complete server response information in the error but this should not include client-triggered cancellations.

@FreakTheMighty
Copy link
Copy Markdown
Author

This comment points out that it’s a low level behavior whatwg/fetch#443 (comment). This can be reproduced using XMLHttpRequest.

My understanding is that when you abort a typical request, like a GET the onabort fires, and that’s it. It is easy to tell what’s happened.

With the OPTIONS request, when you abort, you get the onabort callback AND a very generic error that can’t be disambiguated. Fetch throws the error, as it should, but now if the error bubbles up before the onabort, calling libraries can misinterpret the cause of the error.

The privacy discussions seem to indicate that at a browser level we shouldn’t expect any useful errors to avoid leaking information. I can see why arguably an abort should be handled differently (is the client generating the error), but it seems unlikely that we’ll get a big change like this addressed in Chrome.

I’m not at my laptop, right now, but I’d like to play with some toy examples.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants