Туториал по 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 обратного геокодирования

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

More in Developers →
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 →