Туториал по Node.js Geocoding API: concurrency, retry и стриминг CSV
Production-туториал на Node.js: bounded concurrency через p-limit, retry/backoff на 429, стриминг 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-limitimport 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 orderBatch-ответ сохраняет порядок входа. Позиция 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
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →