Rate limiting

Назначение ограничения частоты запросов

При работе с интерактивными картами большое количество событий может происходить за очень короткий промежуток времени. Перемещение карты, изменение масштаба, обновление слоёв, поиск объектов, загрузка данных с сервера — все эти операции способны генерировать десятки и даже сотни запросов в секунду.

Без механизма ограничения частоты запросов возникают следующие проблемы:

  • чрезмерная нагрузка на сервер;
  • превышение лимитов сторонних API;
  • ухудшение производительности браузера;
  • задержки при отрисовке карты;
  • блокировка со стороны поставщиков картографических сервисов.

Rate limiting — это набор методов, ограничивающих количество выполняемых операций за определённый промежуток времени.

В контексте Leaflet чаще всего ограничиваются:

  • AJAX-запросы;
  • обращения к геокодерам;
  • обновления маркеров;
  • обработчики событий карты;
  • запросы к тайловым серверам;
  • обновления данных в реальном времени.

Причины возникновения избыточных запросов

Рассмотрим типичный пример.

map.on('move', () => {
    fetch('/api/objects');
});

Событие move вызывается непрерывно во время перетаскивания карты.

Если пользователь перемещает карту в течение пяти секунд, обработчик может выполниться сотни раз.

Подобное поведение приводит к:

  • накоплению сетевых запросов;
  • конкуренции между ответами сервера;
  • появлению устаревших данных;
  • лишнему потреблению трафика.

Поэтому практически любые операции, связанные с сетью, требуют ограничения частоты выполнения.


Основные подходы к Rate Limiting

Наиболее распространёнными являются три метода:

  1. Throttle
  2. Debounce
  3. Очередь запросов (Queueing)

Каждый решает разные задачи.


Throttle

Throttle ограничивает частоту вызова функции.

Например, обработчик может выполняться не чаще одного раза в секунду независимо от количества событий.

Схема работы:

События:
||||||||||||||||||||||||

Throttle 1000 ms:
|----|----|----|----|

Простая реализация

function throttle(fn, delay) {
    let lastCall = 0;

    return function (...args) {
        const now = Date.now();

        if (now - lastCall >= delay) {
            lastCall = now;
            fn.apply(this, args);
        }
    };
}

Использование:

const updateData = throttle(() => {
    console.log('Обновление данных');
}, 1000);

map.on('move', updateData);

Теперь функция будет вызываться максимум один раз в секунду.


Throttle для обновления данных карты

Часто данные необходимо получать при перемещении карты.

Например:

const loadObjects = throttle(async () => {

    const bounds = map.getBounds();

    const response = await fetch('/api/objects', {
        method: 'POST',
        body: JSON.stringify({
            north: bounds.getNorth(),
            south: bounds.getSouth(),
            east: bounds.getEast(),
            west: bounds.getWest()
        })
    });

}, 500);

map.on('move', loadObjects);

Даже если карта двигается непрерывно, сервер получит ограниченное количество запросов.


Debounce

Debounce работает иначе.

Функция выполняется только после того, как поток событий прекратился на определённое время.

Схема:

События:
||||||||||||||||||||||||

Пауза

Вызов:
                    |

Это особенно полезно, когда действие должно выполняться только после завершения взаимодействия пользователя.


Реализация Debounce

function debounce(fn, delay) {

    let timer;

    return function (...args) {

        clearTimeout(timer);

        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };

}

Использование:

const updateData = debounce(() => {
    console.log('Загрузка данных');
}, 500);

map.on('move', updateData);

Debounce для событий карты

Часто требуется получить данные только после окончания перемещения.

const fetchData = debounce(async () => {

    const bounds = map.getBounds();

    const response = await fetch('/api/markers');

    const data = await response.json();

    console.log(data);

}, 400);

map.on('move', fetchData);

Пока пользователь перемещает карту, запросы не выполняются.

После остановки выполняется единственный запрос.


Использование события moveend

Leaflet уже предоставляет событие, которое помогает уменьшить количество вызовов.

map.on('moveend', () => {
    console.log('Перемещение завершено');
});

Событие вызывается только один раз после окончания движения карты.

Поэтому во многих случаях debounce вообще не требуется.

Пример:

map.on('moveend', async () => {

    const bounds = map.getBounds();

    const response = await fetch('/api/objects');

});

Такой подход обычно предпочтительнее постоянного отслеживания move.


Ограничение запросов при изменении масштаба

Событие масштабирования также может генерировать большое количество операций.

map.on('zoom', () => {
    console.log('Масштаб изменяется');
});

Лучше использовать:

map.on('zoomend', () => {
    console.log('Масштаб изменён');
});

Или комбинировать с debounce:

const updateLayers = debounce(() => {
    refreshMarkers();
}, 300);

map.on('zoom', updateLayers);

Ограничение запросов геокодирования

Геокодеры обычно имеют строгие ограничения по количеству запросов.

Например, поиск адресов при вводе текста:

input.addEventListener('input', async (e) => {

    const query = e.target.value;

    await fetch(`/search?q=${query}`);

});

Каждый символ создаёт новый запрос.

Правильнее использовать debounce.

const searchAddress = debounce(async (query) => {

    const response =
        await fetch(`/search?q=${query}`);

}, 500);

input.addEventListener('input', e => {
    searchAddress(e.target.value);
});

Теперь запрос будет выполнен только после завершения ввода.


Ограничение запросов к Nominatim

Сервис геокодирования OpenStreetMap Nominatim известен строгими лимитами использования.

Неправильно:

map.on('move', async () => {
    await fetch(nominatimUrl);
});

Правильно:

const reverseGeocode =
    debounce(async () => {

        await fetch(nominatimUrl);

    }, 1000);

map.on('moveend', reverseGeocode);

Такой подход существенно снижает вероятность блокировки.


Очередь запросов

Иногда необходимо гарантировать, что одновременно выполняется ограниченное число запросов.

Для этого используется очередь.

Простейшая очередь

class RequestQueue {

    constructor(limit = 3) {
        this.limit = limit;
        this.running = 0;
        this.queue = [];
    }

    add(task) {

        return new Promise((resolve, reject) => {

            this.queue.push(async () => {

                try {
                    resolve(await task());
                } catch (error) {
                    reject(error);
                }

            });

            this.next();

        });

    }

    next() {

        if (
            this.running >= this.limit ||
            this.queue.length === 0
        ) {
            return;
        }

        const task = this.queue.shift();

        this.running++;

        task().finally(() => {
            this.running--;
            this.next();
        });

    }

}

Использование:

const queue = new RequestQueue(2);

queue.add(() => fetch('/api/1'));
queue.add(() => fetch('/api/2'));
queue.add(() => fetch('/api/3'));
queue.add(() => fetch('/api/4'));

Одновременно будут выполняться только два запроса.


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

Во время перемещения карты предыдущий запрос может стать неактуальным ещё до завершения.

Современный подход основан на AbortController.

let controller;

async function loadData() {

    if (controller) {
        controller.abort();
    }

    controller = new AbortController();

    try {

        const response = await fetch(
            '/api/data',
            {
                signal: controller.signal
            }
        );

        const data = await response.json();

        console.log(data);

    } catch (error) {

        if (error.name !== 'AbortError') {
            console.error(error);
        }

    }

}

Подключение к карте:

map.on('moveend', loadData);

Каждый новый запрос отменяет предыдущий.


Комбинирование Debounce и AbortController

Наиболее распространённый сценарий выглядит так:

let controller;

const updateMapData = debounce(async () => {

    if (controller) {
        controller.abort();
    }

    controller = new AbortController();

    try {

        const response = await fetch(
            '/api/markers',
            {
                signal: controller.signal
            }
        );

        const markers = await response.json();

        renderMarkers(markers);

    } catch (error) {

        if (error.name !== 'AbortError') {
            console.error(error);
        }

    }

}, 500);

map.on('move', updateMapData);

Здесь одновременно решаются две задачи:

  • исключаются лишние запросы;
  • отменяются устаревшие операции.

Rate Limiting при обновлении маркеров

Не только сетевые запросы требуют ограничения.

Большое количество маркеров может серьёзно нагружать браузер.

Неправильно:

map.on('move', () => {
    rebuildMarkers();
});

При непрерывном перемещении функция будет запускаться десятки раз в секунду.

Лучше:

const rebuild =
    throttle(rebuildMarkers, 300);

map.on('move', rebuild);

Ограничение обновления кластеров

При использовании плагина MarkerCluster возможны частые перерасчёты кластеров.

map.on('zoom', () => {
    clusterLayer.refreshClusters();
});

Рациональнее:

const refresh =
    debounce(() => {
        clusterLayer.refreshClusters();
    }, 300);

map.on('zoom', refresh);

Серверное Rate Limiting

Даже если клиент ограничивает частоту запросов, сервер также должен защищаться.

Типичные ограничения:

100 запросов в минуту
1000 запросов в час
10000 запросов в сутки

Сервер может возвращать:

429 Too Many Requests

При работе с Leaflet необходимо предусматривать обработку подобных ответов.

const response = await fetch(url);

if (response.status === 429) {
    console.warn(
        'Превышен лимит запросов'
    );
}

Экспоненциальная задержка (Exponential Backoff)

Если сервер временно отклоняет запросы, применяется стратегия повторных попыток с возрастающей задержкой.

async function fetchWithRetry(
    url,
    retries = 5
) {

    for (let i = 0; i < retries; i++) {

        try {

            const response =
                await fetch(url);

            if (response.ok) {
                return response;
            }

        } catch (e) {}

        const delay =
            Math.pow(2, i) * 1000;

        await new Promise(resolve =>
            setTimeout(resolve, delay)
        );

    }

}

Задержки будут следующими:

1 секунда
2 секунды
4 секунды
8 секунд
16 секунд

Такой механизм снижает нагрузку на сервер во время сбоев.


Практические рекомендации

Для загрузки объектов карты

Использовать:

moveend

или:

debounce(300–500 мс)

Для геокодирования

Использовать:

debounce(500–1000 мс)

Для обновления интерфейса

Использовать:

throttle(100–300 мс)

Для тяжёлых операций

Использовать:

AbortController
+
Debounce

Для большого количества запросов

Использовать:

Queue
+
Concurrency Limit

Типичная архитектура загрузки данных

Современная схема работы Leaflet-приложения обычно выглядит следующим образом:

Перемещение карты
        ↓
      move
        ↓
    Debounce
        ↓
AbortController
        ↓
Получение границ карты
        ↓
API запрос
        ↓
Обработка ответа
        ↓
Обновление маркеров

Такая архитектура обеспечивает:

  • минимальное количество запросов;
  • отсутствие дублирующихся операций;
  • корректную работу при медленном интернете;
  • снижение нагрузки на сервер;
  • соблюдение ограничений сторонних картографических сервисов;
  • плавную работу интерфейса даже при интенсивном взаимодействии с картой.