Туториал по Node.js Geocoding API: concurrency, retry и стриминг CSV

Production-туториал на Node.js: bounded concurrency через p-limit, retry/backoff на 429, стриминг CSV-пайплайн. Весь код проверен.

| April 26, 2026
Туториал по Node.js Geocoding API: concurrency, retry и стриминг CSV

Это рабочий туториал на Node.js по геокодированию адресов через REST API. Код в этом посте был написан, скопирован в файлы и выполнен до публикации. Каждый сниппет работает на чистой установке Node 22 с двумя зависимостями (p-limit, csv-parse). Никаких фреймворков, никакого SDK, никакой магии.

Что у вас будет к концу поста: скрипт, который читает CSV с адресами, геокодирует их параллельно с настраиваемым rate limit, делает retry на 429 с экспоненциальным backoff и пишет CSV из {lat, lng, error}, не загружая входной файл в память. Около 80 строк кода.

Эндпоинт, который используется в посте — csv2geo.com/api/v1. Бесплатный тариф — 1 000 запросов в день, без кредитки. Залогиньтесь и возьмите ключ на /api-keys, если хотите повторять за мной; для демо-эндпоинтов в первых примерах ключ не нужен.

Эндпоинт

Два эндпоинта закрывают 95% реальных задач геокодирования. Forward превращает строку с адресом в координаты. Reverse превращает координаты в адрес. Оба принимают GET (одиночный) или POST (batch).

# Forward (single)
GET https://csv2geo.com/api/v1/geocode?q=ADDRESS&country=US

# Reverse (single)
GET https://csv2geo.com/api/v1/reverse?lat=LAT&lng=LNG

# Batch forward
POST https://csv2geo.com/api/v1/geocode
Body: { "addresses": ["addr1", "addr2", ...] }

# Auth: either ?api_key=KEY query string, or
# Authorization: Bearer KEY header

Так выглядит ответ на одиночный forward-запрос. Это реальный output, а не пример из документации.

{
  "query": "1600 Pennsylvania Avenue NW Washington DC",
  "results": [
    {
      "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500-0005, United States",
      "location": { "lat": 38.89768, "lng": -77.03655 },
      "accuracy": "houseNumber",
      "accuracy_score": 1,
      "components": {
        "house_number": "1600",
        "street": "Pennsylvania Ave NW",
        "city": "Washington",
        "state": "District of Columbia",
        "postal_code": "20500-0005",
        "country": "USA"
      }
    }
  ],
  "meta": { "response_time_ms": 673, "source": "here" }
}

Два поля важнее всего, когда вы пишете клиентский код. results[0].location — это ваши {lat, lng}. results[0].accuracy — это уровень совпадения: "houseNumber" — точка крыши, "street" — центроид улицы, "place" — POI-совпадение, "postcode" — центроид почтового индекса. Используйте его, чтобы выкидывать low-confidence результаты до того, как они засрут датасет.

Первый запрос

В Node 18+ fetch встроен. Никакого npm install для первого вызова не нужно.

// geocode.mjs
const API_KEY = process.env.CSV2GEO_KEY;

async function geocode(address, country = 'US') {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', address);
  url.searchParams.set('country', country);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const data = await res.json();
  return data.results[0]?.location ?? null;
}

console.log(await geocode('1 Apple Park Way, Cupertino, CA'));
// -> { lat: 37.33177, lng: -122.03042 }

Запускаем: CSV2GEO_KEY=geo_live_xxx node geocode.mjs. Форма с заголовком Authorization — рекомендуемая: ключи так не утекают в shell history и в access-логи.

Обратное геокодирование

Та же форма, другие параметры. Передаём lat и lng, получаем адрес.

async function reverse(lat, lng) {
  const url = new URL('https://csv2geo.com/api/v1/reverse');
  url.searchParams.set('lat', String(lat));
  url.searchParams.set('lng', String(lng));

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.results[0] ?? null;
}

const r = await reverse(48.8584, 2.2945); // Eiffel Tower
console.log(r.formatted_address, r.distance_meters);

Reverse-ответы включают поле distance_meters — насколько далеко найденный адрес от входной координаты. Если вы reverse-геокодируете GPS-пинги из доставочного приложения, всё, что больше ~50 м, обычно означает, что плохой был GPS-fix, а не геокодер.

Параллелим, ничего не расплавив

Неправильный способ геокодировать 10 000 адресов в Node — это Promise.all над map из fetch'ей. Это шлёт 10 000 одновременных коннектов в API, на втором батче упирается в rate limit и заваливает event loop отклонёнными промисами. Не делайте так.

Правильный способ — bounded concurrency. Самый чистый инструмент для этого — p-limit: около 50 строк исходников, без транзитивных зависимостей.

npm install p-limit
import pLimit from 'p-limit';

const limit = pLimit(8); // tune to your plan's per-minute rate limit

async function geocode(address) {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', address);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.results[0]?.location ?? null;
}

const addresses = [/* ...10,000 strings... */];
const results = await Promise.all(
  addresses.map(a => limit(() => geocode(a)))
);

Тюнинг concurrency — дело эмпирическое. Бесплатный план ограничен 100 запросами в минуту, так что concurrency 4 — безопасно. Тариф Starter — 1 000/мин, диапазон 16–24 нормальный. Тариф Pro — 10 000/мин: начните с 64 и смотрите на заголовок X-RateLimit-Remaining.

Retry, backoff и rate-limit заголовки

Три вещи в продакшен-геокодере: (1) считайте 429 и 5xx ретраиваемыми, (2) учитывайте заголовок Retry-After, когда он есть, (3) ограничивайте число попыток, чтобы мёртвый ключ не зациклил всё навсегда.

async function geocodeWithRetry(address, { maxAttempts = 4 } = {}) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const url = new URL('https://csv2geo.com/api/v1/geocode');
    url.searchParams.set('q', address);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
    });

    if (res.ok) return res.json();

    // 4xx other than 429 -> non-retriable (bad key, bad input, etc.)
    if (res.status !== 429 && res.status < 500) {
      throw new Error(`HTTP ${res.status}: ${await res.text()}`);
    }

    // 429 / 5xx -> back off and try again
    const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt;
    await sleep(retryAfter * 1000);
  }
  throw new Error(`Gave up after ${maxAttempts} attempts`);
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

На каждом успешном вызове стоит проверять три заголовка:

  • X-RateLimit-Limit — потолок вашего плана в минуту.
  • X-RateLimit-Remaining — что осталось в текущем окне.
  • X-RateLimit-Reset — Unix-секунды до сброса окна.

Если Remaining падает ниже 10% от Limit — добровольно тормозите. Это дешевле, чем биться в 429, и сильно дешевле, чем внезапный всплеск, который кладёт пайплайн целиком.

Batch-эндпоинт

Платные тарифы от Starter и выше открывают batch-эндпоинт, принимающий массив адресов одним POST. До 10 000 за запрос на Pro. Один round-trip вместо N. На бесплатном тарифе batch недоступен — придётся гонять одиночные запросы в цикле.

async function batchGeocode(addresses) {
  const res = await fetch('https://csv2geo.com/api/v1/geocode', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.CSV2GEO_KEY}`,
    },
    body: JSON.stringify({ addresses }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

const out = await batchGeocode([
  '1600 Pennsylvania Ave NW, Washington, DC',
  '1 Apple Park Way, Cupertino, CA',
  '233 S Wacker Dr, Chicago, IL',
]);
// out.results is an array aligned 1:1 with the input order

Batch-ответ сохраняет порядок входа. Позиция N в ответе всегда соответствует позиции N во входе — таскать с собой поле id не нужно, хотя можно.

Стриминг CSV без OOM

Если входной файл — 100 000 строк, не надо делать fs.readFileSync, парсить в гигантский массив и потом map() по нему. Либо память кончится, либо rate limit спалите. Стримьте.

npm install p-limit csv-parse csv-stringify
// stream-geocode.mjs
import { createReadStream, createWriteStream } from 'node:fs';
import { parse } from 'csv-parse';
import { stringify } from 'csv-stringify';
import pLimit from 'p-limit';

const KEY = process.env.CSV2GEO_KEY;
const limit = pLimit(8);

async function geocode(q) {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', q);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
  if (!res.ok) return { lat: '', lng: '', error: `http_${res.status}` };
  const data = await res.json();
  const r = data.results?.[0];
  if (!r) return { lat: '', lng: '', error: 'no_match' };
  if (r.accuracy_score < 0.7) return { lat: '', lng: '', error: 'low_confidence' };
  return { lat: r.location.lat, lng: r.location.lng, error: '' };
}

const out = createWriteStream('out.csv');
const stringer = stringify({ header: true, columns: ['id', 'address', 'lat', 'lng', 'error'] });
stringer.pipe(out);

const tasks = [];
const parser = createReadStream('in.csv').pipe(parse({ columns: true }));
for await (const row of parser) {
  tasks.push(limit(async () => {
    const g = await geocode(row.address);
    stringer.write({ ...row, ...g });
  }));
}
await Promise.all(tasks);
stringer.end();
await new Promise(r => out.on('finish', r));
console.log('done -> out.csv');

Три вещи, которые этот скрипт делает, а код новичков обычно нет:

  • Читает вход стримом (без полной загрузки файла) и пишет выход стримом (без буферизации всего и финального flush).
  • Ограничивает in-flight геокодирование до 8 одновременных запросов через p-limit.
  • Записывает ошибки в отдельный столбец, а не падает на первом фейле. Это разница между скриптом, который дошёл до конца, и скриптом, который надо перезапускать с вопросом «а где он остановился».

Минимальный TypeScript-обёртка

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

// types.ts
export type Accuracy = 'houseNumber' | 'street' | 'place' | 'postcode';

export interface GeocodeResult {
  formatted_address: string;
  location: { lat: number; lng: number };
  accuracy: Accuracy;
  accuracy_score: number;
  components: {
    house_number?: string;
    street?: string;
    city?: string;
    state?: string;
    postal_code?: string;
    country?: string;
  };
}

export interface GeocodeResponse {
  query: string;
  results: GeocodeResult[];
  meta: { response_time_ms: number; source: string };
}

// client.ts
import type { GeocodeResponse, GeocodeResult } from './types';

export async function geocode(
  q: string,
  country = 'US',
): Promise<GeocodeResult | null> {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', q);
  url.searchParams.set('country', country);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = (await res.json()) as GeocodeResponse;
  return data.results[0] ?? null;
}

Два дизайн-решения стоит отметить. Первое: функция возвращает null при no-match вместо throw — пустые results не ошибка, а ответ. Второе: accuracy типизирован как union, так что switch без default — это ошибка компиляции, если API когда-то добавит новое значение. Это тот тип кода, который переживает minor-версии API.

На чём народ спотыкается

Порядок не сохраняется при параллельных одиночных запросах. Promise.all возвращает в порядке входа, но сеть отдаёт ответы в порядке завершения. Если вы стримом пишете результаты в порядке завершения сети — выходные строки перемешаются. Либо стримите запись с ключом по id (пример выше так и делает — каждая строка тащит исходный id), либо буферизуйте до конца.

fetch не бросает исключение на не-2xx. 404 или 429 — это resolved промис, а не reject. Всегда проверяйте res.ok до парсинга тела. Количество тикетов «почему мои результаты undefined» с этой причиной — большое.

Коды стран имеют значение. Параметр country — это hint, а не фильтр. Если передадите country=US для адреса в Торонто — получите неправильный, но правдоподобный результат где-то на севере штата Нью-Йорк. Если у вас смешанный вход — выставляйте country по строке.

Пустые строки — валидный вход, возвращающий мусор. Валидируйте row.address до отправки. Две минуты санитизации входа экономят час «почему lat 0,0?» расследования.

Часто задаваемые вопросы

Нужен ли SDK?

Нет. API — это REST + JSON, а в Node fetch встроен. «SDK», который большинство команд в итоге пишут — это функция geocodeWithRetry выше плюс обёртка под batch-эндпоинт. Около 30 строк.

Работает ли это с TypeScript?

Да. Затипизируйте форму ответа из примера JSON выше. Минимально полезные типы — { results: Array<{ location: { lat: number; lng: number }; accuracy: string; accuracy_score: number; components: Record<string, string> }> }. Если хочется строже — сузьте accuracy до union "houseNumber" | "street" | "place" | "postcode".

Можно ли использовать это из браузера, а не из Node?

Можно, но не стоит класть долгоживущий API-ключ в браузерный код — его прочитает кто угодно. Либо проксируйте через свой бэкенд (тот же код из этого поста, только внутри Express- или Fastify-роута), либо выдавайте короткоживущие ключи для браузера.

Какой rate limit ставить для concurrency?

Возьмите минутный rate limit вашего плана, поделите на 60 и целтесь примерно в столько одновременных запросов. 1 000/мин ÷ 60 ≈ 16. Округляйте вниз. Заголовок X-RateLimit-Remaining — источник истины: подстраивайтесь, если он быстро падает.

Как отличить no-match от успешного low-confidence сопоставления?

data.results — пустой массив на no-match. На low-confidence results[0] существует, но accuracy — "postcode" или "place" вместо "houseNumber" или "street", а accuracy_score сильно меньше 1. Выбирайте порог (0,7 — разумный default) и считайте всё ниже как no-match.

Как геокодировать адреса вне США?

Передавайте правильный код страны в параметре country — ISO alpha-2: DE для Германии, GB для UK, BR для Бразилии. Покрытие — 39 стран сегодня, включая весь топ-10 по числу адресов. На странице API есть подсчёт по странам.

Одиночные запросы или batch-эндпоинт?

Batch — когда у вас ≥100 адресов за раз и план это позволяет. Один POST дешевле для обеих сторон, чем 100 GET'ов, а выигрыш по latency примерно равен среднему времени запроса, умноженному на их количество и поделённому на concurrency. Одиночные проще для стриминговых нагрузок, где адреса приходят постепенно.

Что дальше

Полный референс по API — на csv2geo.com/api. Если предпочитаете Python, туториал по Python идёт по той же структуре с requests и asyncio. Для нагрузок, где удобнее загрузить файл, чем писать клиентский код, batch-геокодер — это тот же бэкенд за веб-UI.

Если найдёте, что X-RateLimit заголовки в каком-то edge-case недосчитывают, или столкнётесь с формой ответа, не описанной в этом посте — контактная форма на сайте идёт к человеку, который её читает. Баг-репорты с curl-репро чинятся быстро.

I.A. / Создатель CSV2GEO

Похожие статьи

Как геокодировать адреса в Python

Как преобразовать адрес в широту и долготу

Цены на API геокодирования — реальная стоимость в 2026

API обратного геокодирования

Пакетное геокодирование

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →