Съдържание на курса

Урок 5 — Асинхронност: да проверява всичко едновременно

От Dorian Chávez · основател на Hábil и архитект на интеграции ·

Време: 2 × 45 мин

Какво изграждаш: едновременния revisor

Какво научаваш: цикъл на събитията (event loop), обещания, async/await, Promise.all срещу allSettled, AbortController и таймаути

След урока ще можеш да

  • Обясниш реда на изпълнение между синхронен код, микрозадачи на обещания и задачи като таймерите.
  • Напишеш функция async, чийто изходен договор е Promise<Estado>.
  • Изпълняваш заявки към няколко услуги едновременно и запазваш реда на конфигурацията в отчета.
  • Избираш между Promise.all и Promise.allSettled според политиката за откази на отчета.
  • Приложиш таймаут с AbortController, предадеш сигнала му и разграничиш отмяната от друг отказ.
  • Поправиш TS2322, когато трансформираш резултатите на Promise.allSettled в състояния на домейна.

Защо, преди как

До предишния урок revisor вече знае какво е Servicio и как да представи Estado: наличният резултат носи HTTP код и продължителност; отказът носи подробност. И все пак функциите, които написахме досега, биха могли да проверяват всяка услуга една след друга. Този ред е лесен за представяне, но е скъпо решение, когато основната операция е да чакаш отговор от мрежата.

Да допуснем, че има три услуги: catálogo, pagos и inventario. Ако всяка заявка отнема приблизително една секунда и ги правиш последователно, отчетът свършва приблизително три секунди по-късно. Докато програмата чака catálogo, не е нужно да заема процесора, за да продължи да чака. И все пак последователната реализация решава да не стартира pagos, докато catálogo не свърши, и да не стартира inventario, докато pagos не свърши. Чакането се натрупва, макар трите заявки да са независими.

Съвместното изпълнение (concurrency) използва точно тази независимост. revisor може да стартира трите заявки, да остави Node да обслужва други събития, докато пристигат отговорите, и да събере резултатите накрая. Това не значи, че програмата изпълнява три инструкции на JavaScript едновременно в една и съща нишка. Значи, че може да има няколко висящи операции, обикновено за вход и изход, без да се блокира, като чака една по една. Ако най-бавната заявка отнема една секунда, едновременният отчет отнема около тази секунда плюс малката работа по организирането на резултатите.

Тази разлика прилича на съвместното изпълнение в Go, но мисловният инструмент не е същият. В Go можеш да пускаш горутини и да ги координираш с канали, групи за изчакване и контексти. В Node обикновеният код на JavaScript в един процес работи основно в една нишка, а средата за изпълнение координира асинхронните операции чрез цикъла на събитията (event loop), обещанията и опашките за работа. Не е нужно да управляваш нишки за повечето HTTP заявки; нужно е да изразиш какво се случва, когато една операция завърши, се провали или бъде отменена.

Думата „едновременно“ не означава и „без ограничение“. Да стартираш всичко наведнъж може да е правилно за малък списък от независими услуги, но би било безотговорно, ако го използваш, без да помислиш, при хиляди цели, база данни с малко връзки или доставчик, който налага лимити на заявките. В този урок множеството е контролираният списък на revisor. По-нататък, когато проектът получава конфигурация и обслужва HTTP, ще можеш да решиш лимит на съвместното изпълнение с реални данни.

Вторият проблем е по-важен от скоростта: един отказ не бива да ти пречи да узнаеш останалите. Ако pagos не отговаря, отчетът пак е полезен, ако казва, че catálogo е налична, а inventario е изчерпала таймаута си. Отчетът за здравето обикновено не се нуждае от политиката „ако една заявка се провали, изтрий всички резултати“; нуждае се да запише всеки резултат поотделно. Тази политика определя дали ще използваш Promise.all, Promise.allSettled или комбинация от двете.

Накрая, да чакаш без ограничение е друг вид грешка. Отдалечена услуга може да стане бавна, връзка може да губи пакети, а цел може да приеме връзката, без да завърши отговора си. Ако revisor не определи лимит, една-единствена заявка може да остави висящ целия цикъл на проверка. timeoutMs на Servicio, който досега беше само част от модела, става правило, което трябва да влияе върху изпълнението. AbortController е стандартният механизъм да се съобщи: „тази операция вече не бива да продължава“.

Целта на урока не е да запомниш думата await. Целта е да проектираш договор за проверка, който може да завърши по три ясни начина: налична, нормален отказ или отказ поради таймаут. Когато това решение се появи в типа и във функцията, която координира обещанията, отчетът остава пълен, дори когато част от системата е в беда.

Понятията

Цикълът на събитията (event loop): да завършиш една инструкция, преди да обслужиш следващото висящо нещо

JavaScript изпълнява първо синхронния код, който има пред себе си. Ако функция извика console.log, отпечатването се случва, преди програмата да продължи със следващия ред. Когато кодът стартира асинхронна операция, като таймер, четене на файл или мрежова заявка, той регистрира продължение за по-късно и позволява на нишката да продължи да работи. Не се върти на място и не блокира процеса, като пита отново и отново дали отговорът вече е пристигнал.

Цикълът на събитията (event loop) е механизмът, който координира тези продължения. Когато стекът от извиквания се освободи, Node може да вземе работа от опашките си и да изпълни следващия блок JavaScript. Изпълнените обещания планират микрозадачи; таймерите планират по-късни задачи. Видимата последица е, че висяща микрозадача се обслужва преди таймер, който вече е готов, макар таймерът да е регистриран по-рано.

// fig05_01.ts
console.log("inicio síncrono");

setTimeout(() => {
  console.log("tarea de temporizador");
}, 0);

Promise.resolve().then(() => {
  console.log("microtarea de promesa");
});

console.log("fin síncrono");
$ npx tsc --strict --target ES2022 --module nodenext fig05_01.ts
$ node fig05_01.js
inicio síncrono
fin síncrono
microtarea de promesa
tarea de temporizador

0 на таймера не означава „изпълни го сега“. Означава „не го изпълнявай, преди да е свършил поне този оборот на текущата работа“. Затова fin síncrono се появява по-рано. Продължението на Promise.resolve().then(...) също не се изпълнява в същия ред, който го е създал: остава висящо като микрозадача и се обслужва след синхронния код, преди да се мине към задачата на таймера.

Не превръщай този ред във формула, с която да управляваш програмата с точност на часовник. Редът между микрозадачи и задачи е полезно правило; конкретната продължителност на мрежова заявка, на таймер или на операция на операционната система не е. Правилната програма не зависи от това един отговор да пристигне „преди десет милисекунди“ на твоя компютър. Зависи от това да реагира правилно, когато отговорът пристигне, се провали или бъде отменен.

Вътре в revisor HTTP заявката стартира работа, която ще продължи извън непосредствения код на JavaScript. Когато функцията извика fetch, не получава тялото на отговора синхронно. Получава обещание и позволява на процеса да продължи да стартира други заявки. Когато отговорът е готов, продължението, свързано с това обещание, влиза във висящата работа, която цикълът на събитията ще може да обслужи.

Това обяснява една важна разлика с обикновената функция. Синхронната функция връща завършена стойност, като string или Estado. Функция, която трябва да чака мрежа, връща обещание за тази стойност. Работата не е завършена при връщането от извикването; тя е представена от обект, който обещава бъдещ резултат.

Обещания и async/await: да направиш видимо, че резултатът ще пристигне по-късно

Promise<T> представя операция, която в крайна сметка завършва със стойност от тип T или завършва отхвърлена с причина. Обещанието не гарантира, че всичко е минало добре: гарантира, че ще има развръзка. Обещанието може да е висящо, изпълнено или отхвърлено. Ако е изпълнено, съдържа очакваната стойност; ако е отхвърлено, изразява, че операцията не е могла да я произведе.

Думата async променя договора на функцията. Ако една функция е маркирана като async, тя винаги връща обещание, дори когато вътре напишеш return "listo". В този случай типът ѝ е Promise<string>, а не string. await чака развръзката на обещание вътре във функция async; ако е изпълнено, произвежда стойността му. Ако е отхвърлено, await хвърля тази причина като изключение на това място.

Фигури 05_02 до 05_07 използват await на най-високото ниво. Изпълни ги в папката figuras/, която създаде в урок 1 и чийто package.json съдържа { "type": "module" }; така --module nodenext ги третира като ESM модули. Без тази конфигурация TypeScript отхвърля await на най-високото ниво.

// fig05_02.ts
async function obtenerEtiqueta(): Promise<string> {
  const nombre = await Promise.resolve("catálogo");
  return `revisando ${nombre}`;
}

const etiqueta = await obtenerEtiqueta();
console.log(etiqueta);
$ npx tsc --strict --target ES2022 --module nodenext fig05_02.ts
$ node fig05_02.js
revisando catálogo

await не превръща асинхронна операция в синхронна. Само позволява да напишеш продължението във форма, подобна на последователен код. Докато obtenerEtiqueta чака обещанието, функцията е суспендирана; това не спира цикъла на събитията и не пречи на други висящи операции да напредват. Когато обещанието се изпълни, функцията възобновява изпълнението си и разрешава собственото си обещание с крайния низ.

Често объркване е да мислиш, че await трябва да се използва при всяко извикване на асинхронна функция. Използва се, когато ти трябва стойността, преди да продължиш с този клон. Ако първо искаш да стартираш няколко заявки и после да изчакаш всички, да сложиш await вътре във всяко завъртане на цикъл ги прави последователни. Позицията на await описва зависимост: ако следващата операция зависи от предишния резултат, чакай; ако не зависи, стартирай я и я координирай по-късно.

Вътре в revisor естественият договор на отделната проверка е Promise<Estado>. Функцията не може да върне завършено Estado веднага, защото още не знае дали услугата ще отговори. Вместо това обещава да предаде състояние, когато заявката свърши или когато превърне отказ в резултат на домейна.

// fig05_03.ts
interface Servicio {
  readonly nombre: string;
}

type Estado = {
  servicio: Servicio;
  tipo: "disponible";
};

async function revisarUno(servicio: Servicio): Promise<Estado> {
  await Promise.resolve();
  return {
    servicio,
    tipo: "disponible",
  };
}

const estado = await revisarUno({ nombre: "catálogo" });
console.log(`${estado.servicio.nombre}: ${estado.tipo}`);
$ npx tsc --strict --target ES2022 --module nodenext fig05_03.ts
$ node fig05_03.js
catálogo: disponible

Анотацията Promise<Estado> е важна, защото документира времевата граница на функцията. Който извика revisarUno, знае, че не може да чете estado.tipo направо от извикването. Трябва да използва await, then или да предаде обещанието на координатор. TypeScript не знае колко ще отнеме една мрежа, но може да не позволи да объркаш висящото обещание със състоянието, което то ще произведе.

В Go функция, която проверява услуга, може да върне стойност и error, след като горутината или функцията завърши. В TypeScript асинхронната функция изразява това чакане вътре в Promise. И двата варианта те принуждават да моделираш отказа; разликата е, че в TypeScript бъдещият резултат е изрична част от типа на връщане.

Promise.all: да стартираш всичко и да чакаш съвкупността

Promise.all получава итерируема съвкупност от обещания и връща ново обещание. То се изпълнява, когато всички обещания се изпълнят, с масив от стойности в същия ред като входа. Последното е полезно за revisor: отговорите могат да свършат в произволен ред, но отчетът може да запази реда, в който човекът е конфигурирал услугите.

Ако едно от обещанията бъде отхвърлено, Promise.all се отхвърля веднага щом узнае за това отхвърляне. Останалите операции не се отменят автоматично; могат да продължат да работят. Променя се резултатът на координиращото обещание: вече няма да има пълен масив от стойности. Тази политика е подходяща, когато всяка част е незаменима, например при зареждане на три файла, нужни за изграждането на една-единствена валидна конфигурация.

// fig05_04.ts
async function consultar(nombre: string): Promise<string> {
  return Promise.resolve(`${nombre}: disponible`);
}

const servicios = ["catálogo", "pagos", "inventario"];
const resultados = await Promise.all(servicios.map(consultar));

for (const resultado of resultados) {
  console.log(resultado);
}
$ npx tsc --strict --target ES2022 --module nodenext fig05_04.ts
$ node fig05_04.js
catálogo: disponible
pagos: disponible
inventario: disponible

map(consultar) извиква consultar по веднъж за всяко име, без да чака вътре в обхода. Резултатът е масив от обещания, а Promise.all изчаква съвкупността. Ако напишеш това:

for (const servicio of servicios) {
  const resultado = await consultar(servicio);
  console.log(resultado);
}

заявките биха се случвали една по една. Не винаги е неправилно: би било подходящо, ако втората заявка се нуждае от идентификатор, получен от първата. Но за независими услуги би било натрупано чакане без полза.

Вътре в revisor Promise.all може да е правилният инструмент, дори когато всяка услуга може да се провали. Ключът е да превърнеш всеки отделен отказ в стойност EstadoFalla вътре в revisarUno. Тогава отделното обещание не се отхвърля заради предвиден отказ: изпълнява се със състояние, което описва този отказ. Координаторът може да използва Promise.all, защото всички нормални пътища произвеждат елемент на отчета.

Това разделяне изяснява отговорностите. revisarUno решава как да преведе мрежово изключение, отмяна или невалиден отговор в EstadoFalla. revisarTodos само координира съвкупност от Promise<Estado>. Отчетът винаги получава списък от състояния и не се нуждае да познава технически изключения за всяка услуга.

Promise.allSettled: да запазиш всяка развръзка, преди да решиш какво означава

Promise.allSettled също изчаква цялата съвкупност, но не се отхвърля, ако отделно обещание се провали. Връща масив от дискриминирани обекти. Всеки обект има status: "fulfilled" и value, или status: "rejected" и reason. Полезен инструмент е, когато координацията трябва да наблюдава всички технически развръзки, дори ако някои операции не са стигнали да произведат стойност.

// fig05_05.ts
function tareas(): Promise<string>[] {
  return [
    Promise.resolve("catálogo"),
    Promise.reject(new Error("conexión rechazada")),
    Promise.resolve("inventario"),
  ];
}

try {
  await Promise.all(tareas());
} catch (error: unknown) {
  if (error instanceof Error) {
    console.log(`Promise.all: ${error.message}`);
  }
}

const resultados = await Promise.allSettled(tareas());

for (const resultado of resultados) {
  if (resultado.status === "fulfilled") {
    console.log(`${resultado.value}: disponible`);
  } else if (resultado.reason instanceof Error) {
    console.log(`pagos: falla (${resultado.reason.message})`);
  }
}
$ npx tsc --strict --target ES2022 --module nodenext fig05_05.ts
$ node fig05_05.js
Promise.all: conexión rechazada
catálogo: disponible
pagos: falla (conexión rechazada)
inventario: disponible

Втората съвкупност от задачи е умишлена. Обещанието вече има развръзка; не се „рестартира“, когато го изчакаш отново. Функцията tareas създава нова съвкупност, за да демонстрира поотделно политиката на all и тази на allSettled.

Обърни внимание и на стесняването на типа (narrowing). TypeScript не позволява да четеш resultado.value, без да провериш, че status е "fulfilled", защото отхвърлените резултати нямат това свойство. По същия начин reason принадлежи на отхвърления случай. Това е същият принцип като при дискриминираните обединения от урок 3, приложен към тип от стандартната библиотека.

Promise.allSettled не е автоматично по-добър. Има концептуална цена: сега координаторът познава подробности за обещания, които може би е трябвало да са били превърнати по-рано в речника на домейна. За revisor го използвай, ако наистина трябва да разграничаваш „функцията за проверка е произвела състояние“ от „самата функция е имала неочакван отказ“. Ако всички очаквани откази вече се трансформират в EstadoFalla, Promise.all прави договора по-малък и по-пряк.

В Go съвкупност от горутини може да изпраща всеки резултат през канал, а координаторът решава дали да прекъсне при първата грешка, или да изчака всички. Promise.all и Promise.allSettled предлагат равностойни политики за съвкупност от асинхронни операции. Нито една не замества проектирането на резултата: трябва да решиш дали грешката е данна в отчета, или условие, което обезсилва цялата операция.

AbortController и таймаути: да отмениш е изрично решение

Таймаутът не е обещание, че една операция ще свърши бързо. Той е решение да спреш да я чакаш, когато прекрачи лимит. За да приложиш това решение, са ти нужни две части: нещо, което планира отмяната, и операция, която слуша сигнала за отмяна. AbortController произвежда AbortSignal; координиращата функция пази контролера и предава сигнала на операцията.

Когато извикаш controller.abort(razon), signal.aborted става true, а потребителите на сигнала получават събитието за отмяна. API като fetch приемат signal, за да прекъснат висяща заявка. Собствените ти асинхронни функции също могат да приемат сигнала и да отхвърлят обещанието си, когато бъдат отменени.

// fig05_06.ts
function esperarCancelacion(signal: AbortSignal): Promise<void> {
  return new Promise((_resolver, rechazar) => {
    signal.addEventListener(
      "abort",
      () => rechazar(signal.reason),
      { once: true },
    );
  });
}

async function conLimite(): Promise<void> {
  const controlador = new AbortController();
  const temporizador = setTimeout(() => {
    controlador.abort(new Error("tiempo límite"));
  }, 0);

  try {
    await esperarCancelacion(controlador.signal);
  } catch (error: unknown) {
    if (error instanceof Error) {
      console.log(error.message);
    }
  } finally {
    clearTimeout(temporizador);
  }
}

await conLimite();
$ npx tsc --strict --target ES2022 --module nodenext fig05_06.ts
$ node fig05_06.js
tiempo límite

finally не е декорация. Ако заявката свърши преди таймаута, трябва да изчистиш таймера, за да не отмени операция, която вече е свършила, нито да държи ненужна работа висяща. По същия начин не създавай единствен контролер за всички услуги, ако всеки timeoutMs е независим. Отмяната на inventario не бива да прекъсне catálogo по погрешка.

Причината за прекъсването заслужава да се превърне в четима подробност. AbortSignal съобщава, че нещо е отменено, но отчетът трябва да реши дали е било заради лимит, заради коректно спиране (graceful shutdown) или заради отмяна, поискана от друго място. В този урок отмяната поради лимит се превръща в EstadoFalla с detalle: "tiempo límite"; по-нататък моделът може да добави конкретен вариант, ако домейнът има нужда да го разграничава визуално.

Вътре в revisor сигналът преминава през договора на функцията, която прави заявката. Важно е да не го скриваш в глобална променлива, нито да го създаваш на място, което функцията за заявка не може да наблюдава. Който стартира проверката, притежава контролера; който върши отменяема работа, получава сигнала.

// fig05_07.ts
interface Servicio {
  readonly nombre: string;
  readonly timeoutMs: number;
}

type EstadoDisponible = {
  servicio: Servicio;
  tipo: "disponible";
  codigoHttp: number;
  duracionMs: number;
};

type EstadoFalla = {
  servicio: Servicio;
  tipo: "falla";
  detalle: string;
};

type Estado = EstadoDisponible | EstadoFalla;

type Consultar = (
  servicio: Servicio,
  signal: AbortSignal,
) => Promise<{ codigoHttp: number; duracionMs: number }>;

function esperarAbort(signal: AbortSignal): Promise<never> {
  return new Promise((_resolver, rechazar) => {
    signal.addEventListener(
      "abort",
      () => rechazar(signal.reason),
      { once: true },
    );
  });
}

const consultarDePrueba: Consultar = async (servicio, signal) => {
  if (servicio.nombre === "catálogo") {
    return { codigoHttp: 200, duracionMs: 0 };
  }

  if (servicio.nombre === "pagos") {
    throw new Error("conexión rechazada");
  }

  return esperarAbort(signal);
};

async function revisarUno(
  servicio: Servicio,
  consultar: Consultar,
): Promise<Estado> {
  const controlador = new AbortController();
  const temporizador = setTimeout(() => {
    controlador.abort(new Error("tiempo límite"));
  }, servicio.timeoutMs);

  try {
    const respuesta = await consultar(servicio, controlador.signal);
    return {
      servicio,
      tipo: "disponible",
      codigoHttp: respuesta.codigoHttp,
      duracionMs: respuesta.duracionMs,
    };
  } catch (error: unknown) {
    return {
      servicio,
      tipo: "falla",
      detalle: error instanceof Error ? error.message : "falla desconocida",
    };
  } finally {
    clearTimeout(temporizador);
  }
}

async function revisarTodos(
  servicios: readonly Servicio[],
  consultar: Consultar,
): Promise<Estado[]> {
  return Promise.all(
    servicios.map((servicio) => revisarUno(servicio, consultar)),
  );
}

function lineaReporte(estado: Estado): string {
  if (estado.tipo === "disponible") {
    return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp}`;
  }

  return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}

const estados = await revisarTodos(
  [
    { nombre: "catálogo", timeoutMs: 100 },
    { nombre: "pagos", timeoutMs: 100 },
    { nombre: "inventario", timeoutMs: 0 },
  ],
  consultarDePrueba,
);

for (const estado of estados) {
  console.log(lineaReporte(estado));
}
$ npx tsc --strict --target ES2022 --module nodenext fig05_07.ts
$ node fig05_07.js
catálogo: HTTP 200
pagos: falla (conexión rechazada)
inventario: falla (tiempo límite)

Примерът използва заменяема функция за заявка, за да отдели координацията от подробностите на транспорта. Не отваря истински връзки и не измерва истински продължителности; затова пробната заявка връща duracionMs: 0 и тази стойност не се появява в изхода. Тук Servicio е нарочно опростена до nombre и timeoutMs: още не се нуждае от url. Договорът Consultar вече предава както codigoHttp, така и duracionMs, за да може по-късната реализация да измерва истинска HTTP заявка, а урок 8 да свърже транспорта, без да променя договора на отчета. Политиката за съвместно изпълнение не се променя: всяка услуга получава собствен сигнал, превежда развръзката си в Estado, а координаторът изчаква всички проверки.

Грешката, която ще видиш

Promise.allSettled не връща директно типа на стойността на обещанията. Връща PromiseSettledResult<T>[], защото трябва да представи както изпълнения, така и отхвърляния. Ако се опиташ да го присвоиш на Estado[], TypeScript дава TS2322.

// fig05_08.ts
type Estado = {
  tipo: "disponible";
};

async function revisar(): Promise<Estado[]> {
  const tareas: Promise<Estado>[] = [];
  const resultados: Estado[] = await Promise.allSettled(tareas);
  return resultados;
}
$ npx tsc --strict --target ES2022 --module nodenext fig05_08.ts
fig05_08.ts(8,9): error TS2322: Type 'PromiseSettledResult<Estado>[]' is not assignable to type 'Estado[]'.
  Type 'PromiseSettledResult<Estado>' is not assignable to type 'Estado'.
    Property 'tipo' is missing in type 'PromiseFulfilledResult<Estado>' but required in type 'Estado'.

TS2322 означава, че се опитваш да присвоиш един тип на друг, несъвместим с него. С TypeScript 7.0.2 диагностиката посочва първо, че дори изпълненият случай е обвивка PromiseFulfilledResult<Estado>, а не Estado: липсва му директно tipo. Отхвърленият случай има, напротив, status и reason. Решението не е твърдение като as Estado[], защото то би заличило решението, което още липсва. Трябва да обходиш резултатите, да провериш status и да превърнеш всеки случай в съответното състояние.

const resultados = await Promise.allSettled(tareas);

return resultados.map((resultado, indice): Estado => {
  if (resultado.status === "fulfilled") {
    return resultado.value;
  }

  return {
    servicio: servicios[indice],
    tipo: "falla",
    detalle: "la revisión no terminó",
  };
});

Това решение те принуждава да решиш на коя услуга съответства отхвърленият резултат. Затова е удобно да запазиш масива servicios и да не зависиш от реда на завършване. То показва и защо, когато revisarUno вече превръща собствените си откази в EstadoFalla, може да е по-ясно да се използва Promise.all: координираният резултат вече има крайния тип на отчета.

Друга честа грешка няма код на TypeScript: да забравиш да прихванеш отхвърлянето на обещание. В Node необработено отхвърляне може да прекрати процеса или да произведе предупреждение според режима на изпълнение. Не го поправяй, като добавиш catch(() => {}), който заличава информацията. Прихвани причината и я превърни в отказ, който отчетът може да обясни, или я хвърли отново, ако наистина трябва да спре целия цикъл.

Какво се прави погрешно

  • Да поставиш await вътре в цикъл за независими операции. Кодът изглежда подреден, но всяка заявка чака предишната. Първо създай масива от обещания и после използвай Promise.all или Promise.allSettled, за да ги координираш.

  • Да използваш Promise.all, очаквайки автоматичен частичен отчет. Promise.all отхвърля при първото наблюдавано отхвърляне. Другите операции могат да останат живи, но резултатът им престава да е достъпен чрез това координиращо обещание. Използвай allSettled или превърни отделния отказ в EstadoFalla.

  • Да използваш Promise.allSettled по навик. Може да скрие, че отделна функция не е определила правилно договора си за грешки. Ако всяка проверка трябва да завърши като Estado, преведи грешката в revisarUno и използвай Promise.all, за да изразиш, че съвкупността винаги произвежда състояния.

  • Да бъркаш съвместното изпълнение (concurrency) с паралелизма. Няколко заявки могат да са висящи едновременно, без JavaScript да изпълнява няколко части на функцията ти в един и същи момент. Ползата идва от това да не блокираш нишката, докато чакаш вход и изход, а не от обещание за повече процесор.

  • Да чакаш със setTimeout, за да „дадеш време“ на обещание. Таймерът не доказва, че операцията е свършила, и не синхронизира правилно резултатите. Чакай обещанието, което представя работата; използвай таймер само като изрична част от таймаут.

  • Да създадеш AbortController и да не предадеш signal на операцията. Извикването на abort() не спира по магия произволен код. Операцията трябва да приема и да наблюдава сигнала, както fetch или собствена функция, която регистрира събитието abort.

  • Да не изчистиш таймера във finally. Ако операцията свърши рано, таймерът остава висящ и може да прекъсне по-късно или да държи процеса жив. clearTimeout трябва да се изпълни както при успех, така и при отказ.

  • Да превръщаш каквато и да е грешка в текст чрез твърдение. В catch стойността е unknown при strict. Провери error instanceof Error, преди да четеш message; за други стойности използвай безопасна подробност, решена съзнателно.

  • Да измерваш продължителност с измислени стойности в продукция. Примерът използва 0, за да е изходът му детерминиран. Истинската реализация трябва да измерва около операцията и да реши каква мерна единица и точност ще има duracionMs.

Упражнения

Упражнение 1 — Две заявки без натрупано чакане

Напиши consultar(nombre): Promise<string> с Promise.resolve. Тя получава имената catálogo, pagos и inventario; стартирай трите заявки с map и използвай Promise.all, за да отпечаташ резултата на всяка в реда на списъка. След това препиши програмата с for...of и await вътре в цикъла; обясни защо тази втора версия би била последователна, ако функцията правеше истинска мрежова заявка.

Упражнение 2 — Отчет, който пази отказите

Създай три задачи: една изпълнена за catálogo, една отхвърлена с new Error("sin conexión") за pagos и една изпълнена за inventario. Използвай Promise.allSettled, за да ги превърнеш в масив от Estado. Всеки резултат трябва да има tipo: "disponible" или tipo: "falla" и да пази името на услугата. Не използвай as Estado[].

Упражнение 3 — Таймаут за всяка услуга

Дефинирай Consultar като (servicio: Servicio, signal: AbortSignal) => Promise<{ codigoHttp: number; duracionMs: number }> и го използвай в revisarUno(servicio, consultar). Добави AbortController, таймер, базиран на servicio.timeoutMs, и блок finally, който изчиства таймера. Напиши пробна заявка, която се разрешава за catálogo и чака сигнала за прекъсване за inventario. Отчетът трябва да показва catálogo налична, а inventario с отказ, чиято подробност е tiempo límite.

Упражнение 4 — Политика на координатора

Реализирай два координатора за един и същ списък от услуги. Първият трябва да използва Promise.all върху версия на revisarUno, която винаги превръща предвидените откази в EstadoFalla. Вторият трябва да използва Promise.allSettled върху функция, която може да се отхвърли. Опиши в един абзац кой би използвал за основния отчет на revisor и кое конкретно условие би те накарало да избереш другия.

Решения

Решение 1

Същественото е да отделиш стартирането на операциите от чакането на стойностите им. map произвежда всички обещания, преди Promise.all да изчака целия масив.

async function consultar(nombre: string): Promise<string> {
  return Promise.resolve(`${nombre}: disponible`);
}

const nombres = ["catálogo", "pagos", "inventario"];
const resultados = await Promise.all(nombres.map(consultar));

for (const resultado of resultados) {
  console.log(resultado);
}

С истинска мрежа await consultar(nombre) вътре в цикъла би попречил да се стартира pagos, докато catálogo още е висящ. Резултатът би могъл да изглежда същият, но общото време би натрупало чаканията.

Решение 2

Решението трябва да редуцира всеки PromiseSettledResult чрез дискриминанта му status. Масивът servicios пази услугата, свързана с всяка позиция.

const estados = resultados.map((resultado, indice): Estado => {
  const servicio = servicios[indice];

  if (resultado.status === "fulfilled") {
    return {
      servicio,
      tipo: "disponible",
      codigoHttp: resultado.value.codigoHttp,
      duracionMs: resultado.value.duracionMs,
    };
  }

  return {
    servicio,
    tipo: "falla",
    detalle:
      resultado.reason instanceof Error
        ? resultado.reason.message
        : "falla desconocida",
  };
});

Няма автоматично преобразуване на отхвърлен резултат в EstadoFalla. Този превод е решение на домейна и трябва да е написан.

Решение 3

Всяко извикване се нуждае от собствен контролер и собствен таймер. Сигналът се предава на функцията, която може да бъде отменена; блокът finally изчиства временния ресурс по всеки път.

async function revisarUno(
  servicio: Servicio,
  consultar: Consultar,
): Promise<Estado> {
  const controlador = new AbortController();
  const temporizador = setTimeout(() => {
    controlador.abort(new Error("tiempo límite"));
  }, servicio.timeoutMs);

  try {
    const respuesta = await consultar(servicio, controlador.signal);
    return {
      servicio,
      tipo: "disponible",
      codigoHttp: respuesta.codigoHttp,
      duracionMs: respuesta.duracionMs,
    };
  } catch (error: unknown) {
    return {
      servicio,
      tipo: "falla",
      detalle: error instanceof Error ? error.message : "falla desconocida",
    };
  } finally {
    clearTimeout(temporizador);
  }
}

Функцията връща Estado, дори когато заявката не отговаря. Това позволява на координатора на отчета да използва Promise.all, без да губи останалите резултати.

Решение 4

За основния отчет бих използвал Promise.all върху проверки, които превръщат предвидените откази в EstadoFalla. Резултатът има еднороден договор: една проверка за всяка конфигурирана услуга, в същия ред, без технически изключения, които таблото да трябва да тълкува.

Бих използвал Promise.allSettled, когато по-долен слой може да отхвърля по причини, които още се нуждаят от отделна диагностика, например пакет от задачи за инициализация, където трябва да запиша кои дори не са стигнали да създадат състояние. В такъв случай координаторът трябва изрично да трансформира всяко отхвърляне, преди да предаде данни на останалата част от програмата.

Как разбирам, че съм успял

  • node --version започва с v24.
  • npx tsc --version отпечатва Version 7.0.2.
  • След като компилираш и изпълниш fig05_01.ts, редовете се появяват в този ред: начален синхронен код, краен синхронен код, микрозадача на обещание и задача на таймер.
  • След като компилираш и изпълниш fig05_05.ts, се появява отказ на pagos, без това да попречи catálogo и inventario да се отпечатат като налични.
  • След като компилираш и изпълниш fig05_07.ts, изходът съдържа точно по един ред за catálogo, pagos и inventario, с отказа по таймаут за inventario.
  • След като компилираш fig05_08.ts, получаваш TS2322 при присвояването на Promise.allSettled към Estado[] и можеш да обясниш защо твърдението не е поправка.
  • Можеш да посочиш къде се създава, къде се предава и къде се използва AbortSignal на една проверка.

За допълнително четене

  • TypeScript Handbook: More on Functions — официална документация за договорите на функциите и типовете на връщане; консултирано на 2 октомври 2026 г.

  • Node.js: AbortController и AbortSignal — официална документация за глобалните API за отмяна в Node; консултирано на 2 октомври 2026 г.

  • MDN: Promise.allSettled() — справочник за изпълнените и отхвърлените резултати на съвкупност от обещания; консултирано на 2 октомври 2026 г.

  • MDN: Модел на изпълнение на JavaScript — обяснение на цикъла на събитията (event loop), стека от извиквания и опашките за работа; консултирано на 2 октомври 2026 г.

Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx