[whatwg/fetch] New issue proposal: Making Fetch Promises work better with for (Promise Combinator, AbortController & AbortSignal) APIs (Issue #1831)

isocroft created an issue (whatwg/fetch#1831)

### What is the issue with the Fetch Standard?

One of the major downsides of any of the Promise combinators (e.g. Promise.race, Promise.any, Promise.all and Promise.allSettled) is that if any of the promises in the array of promises passed to a Promise combinator rejects, all other promises MUST run to completion and be settled in the background by the Event Loop.

I understand that promise cancellation is quite tricky. My meaning is as follows:

1. The `AbortController` object can only cancel promises that have already started.
2. Aborting immediately completed promises has unpredictable outcomes at best (it should be a silent no-op). 
3. Trying to abort requests already in progress might pose challenges (with the current design for Promises).

Furthermore, **AbortController**s are not as composable as **AbortSignal**s. The `AbortSignal.any(...)` API is apt for composing signals. Yet, a single `AbortController` object is only available within the function it is created in, limiting its use for aborting asynchronous operations outside the function it was created in. My point is a single `AbortController` object cannot be composed easily over other `AbortController` objects.

Also, If the `AbortController` object were placed in a larger lexical scope, it is very difficult to write code to allow one `AbortController` abort either of two or more concurrent tasks that are racing. See [this code snippet](https://gist.github.com/isocroft/0d435dc4e021b62dc1b9ddf31424c86f).

All these issues i believe are solvable if every instance of a Promise had an `abort()` method (which will be a silent no-op when called if the promise is settled)

This leads me to my proposal proper.

I propose an amendment to the way the Promise Constructor is setup which can be coded in a backward compatible way. See below:

```javascript
const promise = new Promise((resolve, reject, signal) => {
  let controlSentinel = 0;

  const timerId = setTimeout(() => {
    if (typeof timerId === "number") {
      clearTimeout(timerId);
    }

    if (controlSentinel !== 0) {
     // Control how aborting the promise impacts ongoing concurrent operation (1)
      return;
    }

   try {
      // Control how aborting the promise impacts ongoing concurrent operation (2)
      if (signal) {
        signal.throwIfAborted();
      }
    } catch (e) {
      reject(e);
    }

    resolve(true);
  }, 3400);

  if (!signal) {
    // Backward compatibility with native definitions without shim. 
    return;
  }

  if (!signal.aborted) {
    signal.addEventListener('abort', () => {
      if (typeof timerId === "number") {
        clearTimeout(timerId);
      }

      controlSentinel = -1;
      reject(signal.reason || this.reason);
    });
  }
});

// Abort the promise
promise.abort();

const controller = new AbortController();

// Set a signal on the promise
promise.signal = AbortSignal.any([
  AbortSignal.timeout(4000),
  controller.signal
]);
``` 

>Polyfill current native implementation to eliminate issues with backward compatibility (👇🏾)

```javascript
;(function (global) {
  if (typeof global.AbortSignal === "undefined") {
    global.AbortSignal = {};
  }

  if (globalAbortSignal.timeout !== "function") {
    global.AbortSignal.timeout = function timeout (durationInMillis = 0) {
      var ctrl = new AbortController()
      setTimeout(() => ctrl.abort(), durationInMillis);
      return ctrl.signal;
    }
  }

  if (global.AbortSignal.any !== "function") {
    global.AbortSignal.any = function any (arrayOfSignals = []) {
      if (Array.isArray(arrayOfSignals)) {
        var ctrl = new AbortController();
         for (signal of arrayOfSignals) {
           if (typeof signal['throwIfAborted'] !== "function"
             && signal['addEventListener'] !== "function") {
               continue;
          }
          signal.addEventListener('abort', () => ctrl.abort());
        }
        return ctrl.signal;
      }
    }
  }




  var nativePromiseConstructor = global.Promise.toString().includes('[native code]') 
    ? global.Promise
    : null;

  if (nativePromiseConstructor === null) {
    // Maybe don't polyfill a polyfill "😂"
    return;
  }

   global.Promise = function Promise (callback) {
     if (typeof callback !== "function") {
       throw new TypeError(
         "Promise resolver " + JSON.stringify(callback) + " is not a function"
       );
     }

     var controlObject = new AbortController();
     this.control = controlObject;
     this.reason = controlObject.signal.reason;
     this._signal = null;
     return nativePromiseConstructor.call(this, callback.bind(controlObject.signal));
   }

   Promise.prototype.abort = function () {
      if (!this.signal.aborted) {
        return this.control.abort();
      }
      // Silent no-op...
   }

   Object.defineProperty(Promise.prototype, 'signal', {
     enumerable: false,
     configurable: true,
      get () {
         if (this._signal === null) {
           return this.control.signal;
         }
         return this._signal;
       },
       set ($signal) {
         if ($signal instanceof AbortSignal) {
           var currentSignal = this._signal || this.control.signal;
           this._signal = AbortSignal.any([
             currentSignal,
             $signal
           ]);

           if (!$signal.aborted) {
             $signal.addEventListener('abort', () => {
               this.reason = this._signal.reason;
               this.abort();
             });
           }
         }
       }
   });
}(window));
```

Additionally, `window.fetch` can be updated (without breaking backward compatibility) such that the `signal` property on the second argument **RequestInit** when passed in is written to `promise.signal` such that when the `signal` is aborted, the logic in the promise resolver function for `window.fetch` can further  be used to abort the HTTP request (on a granular level) by destroying the TCP socket instance (or a silent no-op if the HTTP request is finished/completed already). See [this PR on the nodejs project](https://github.com/nodejs/node/pull/33120/files#diff-e0660764d499aa140730aa50a71ca9f28f73e60ff716b73a51fd5357d3177127R27) for reference.

-- 
Reply to this email directly or view it on GitHub:
https://github.com/whatwg/fetch/issues/1831
You are receiving this because you are subscribed to this thread.

Message ID: <whatwg/fetch/issues/1831@github.com>

Received on Tuesday, 27 May 2025 19:13:27 UTC