Урок 11

Практика: асинхронные паттерны

Загрузка с кешем

const cache = new Map();

async function fetchWithCache(url) {
    if (cache.has(url)) {
        return cache.get(url);
    }

    const promise = fetch(url).then((r) => r.json());
    cache.set(url, promise);

    try {
        return await promise;
    } catch (error) {
        cache.delete(url);
        throw error;
    }
}

Кешируется промис, а не результат. Два одновременных вызова — один запрос.

Ограничение параллельности

async function pool(items, limit, fn) {
    const results = new Array(items.length);
    let index = 0;

    async function worker() {
        while (index < items.length) {
            const i = index++;
            results[i] = await fn(items[i], i);
        }
    }

    const workers = Array.from({ length: limit }, worker);
    await Promise.all(workers);

    return results;
}

await pool(urls, 5, (url) => fetch(url).then((r) => r.json()));

Пять одновременных запросов, остальные ждут.

Retry с backoff

async function retry(fn, { attempts = 3, baseDelay = 1000, maxDelay = 10000 } = {}) {
    let lastError;

    for (let i = 0; i < attempts; i++) {
        try {
            return await fn();
        } catch (error) {
            lastError = error;

            if (i === attempts - 1) break;

            const delay = Math.min(baseDelay * 2 ** i, maxDelay);
            const jitter = Math.random() * delay * 0.1;
            await sleep(delay + jitter);
        }
    }

    throw lastError;
}

Jitter предотвращает «стадо» одновременных retry.

Circuit Breaker

При частых ошибках — прекратить попытки:

class CircuitBreaker {
    constructor(fn, { threshold = 5, timeout = 60000 } = {}) {
        this.fn = fn;
        this.threshold = threshold;
        this.timeout = timeout;
        this.failures = 0;
        this.state = 'closed';
        this.nextAttempt = 0;
    }

    async call(...args) {
        if (this.state === 'open') {
            if (Date.now() < this.nextAttempt) {
                throw new Error('Circuit open');
            }
            this.state = 'half-open';
        }

        try {
            const result = await this.fn(...args);
            this.onSuccess();
            return result;
        } catch (error) {
            this.onFailure();
            throw error;
        }
    }

    onSuccess() {
        this.failures = 0;
        this.state = 'closed';
    }

    onFailure() {
        this.failures++;
        if (this.failures >= this.threshold) {
            this.state = 'open';
            this.nextAttempt = Date.now() + this.timeout;
        }
    }
}

Очередь задач

class TaskQueue {
    constructor(limit = 1) {
        this.limit = limit;
        this.running = 0;
        this.queue = [];
    }

    add(task) {
        return new Promise((resolve, reject) => {
            this.queue.push({ task, resolve, reject });
            this.next();
        });
    }

    next() {
        if (this.running >= this.limit || !this.queue.length) return;

        const { task, resolve, reject } = this.queue.shift();
        this.running++;

        task()
            .then(resolve)
            .catch(reject)
            .finally(() => {
                this.running--;
                this.next();
            });
    }
}

const queue = new TaskQueue(3);
queue.add(() => fetchData(1));
queue.add(() => fetchData(2));

Дедупликация

Одинаковые запросы — один вызов:

const pending = new Map();

function dedupe(key, fn) {
    if (pending.has(key)) {
        return pending.get(key);
    }

    const promise = fn().finally(() => {
        pending.delete(key);
    });

    pending.set(key, promise);
    return promise;
}

dedupe('users', () => fetchUsers());
dedupe('users', () => fetchUsers()); // тот же промис

Отмена устаревших запросов

let controller = null;

async function search(query) {
    controller?.abort();
    controller = new AbortController();

    try {
        const res = await fetch(`/api/search?q=${query}`, {
            signal: controller.signal,
        });
        return await res.json();
    } catch (error) {
        if (error.name === 'AbortError') return null;
        throw error;
    }
}

Новый поиск отменяет старый.

Таймаут

function withTimeout(promise, ms) {
    return Promise.race([
        promise,
        new Promise((_, reject) =>
            setTimeout(() => reject(new Error('Timeout')), ms)
        ),
    ]);
}

await withTimeout(fetchData(), 5000);

Прогресс

async function upload(file, onProgress) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = (e) => {
            if (e.lengthComputable) {
                onProgress(e.loaded / e.total);
            }
        };

        xhr.onload = () => resolve(xhr.response);
        xhr.onerror = () => reject(new Error('Upload failed'));

        xhr.open('POST', '/api/upload');
        xhr.send(file);
    });
}

await upload(file, (progress) => console.log(`${progress * 100}%`));

Итоги

  • Кеш промисов для дедупликации
  • Пул воркеров для ограничения
  • Retry с backoff и jitter
  • Circuit breaker для защиты
  • Отмена старых запросов
  • withTimeout для ограничения по времени