Go Geocoding Tutorial: Goroutines, ограниченная конкурентность и backoff
Production Go tutorial по geocoding: goroutine pools, semaphore-bounded concurrency, exponential backoff на 429. Компилируется и работает.
Go — это язык, к которому стоит обратиться, когда нужна конкурентность без церемоний async/await. Goroutines — это stack-и по 2 КБ, channels — типизированные pipes, а стандартная библиотека net/http достаточно хороша для production с первого дня. Нет event loop, который надо учить, нет ключевого слова await, которое надо рассыпать по каждой функции, нет проблемы coloured-function. Ты пишешь линейный код и распараллеливаешь его через go и semaphore-channel.
Это рабочий Go tutorial по geocoding адресов через REST API. Каждый snippet был записан в .go файл, прогнан через go build и выполнен на живом API на чистой установке Go 1.22 перед публикацией. Нет SDK, нет third-party HTTP библиотеки, нет framework — только net/http, encoding/json, encoding/csv и context. В итоге у тебя будет программа, которая стримит CSV любого размера, геокодирует строки настраиваемым goroutine pool, ретраит 429 и 5xx с jittered exponential backoff, уважающим Retry-After, и пишет {lat, lng, error} CSV без удержания входного файла в памяти. Около 80 строк Go.
Endpoint, используемый в статье, — csv2geo.com/api/v1. Free tier — 1000 forward/reverse запросов в день плюс 100 batch строк в день, без кредитки. Войди и забери ключ на /api-keys, чтобы повторять.
Endpoint
Два endpoint-а покрывают почти всю реальную работу по geocoding. Forward превращает строку адреса в координаты. Reverse превращает координаты в адрес. Оба принимают GET (single) или 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 Ave 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 — уровень match-а: "houseNumber" — это rooftop, "street" — центроид улицы, "place" — POI match, "postcode" — центроид postcode-а. Числовой accuracy_score (0.0–1.0) даёт более тонкий threshold; см. geocoding confidence scores explained, чтобы выбрать.
Первый запрос
net/http плюс encoding/json — это всё, что нужно. Первая версия возвращает только координаты, чтобы можно было end-to-end проверить wire format.
// geocode.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
const baseURL = "https://csv2geo.com/api/v1"
type Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type GeocodeResult struct {
FormattedAddress string `json:"formatted_address"`
Location Location `json:"location"`
Accuracy string `json:"accuracy"`
AccuracyScore float64 `json:"accuracy_score"`
}
type GeocodeResponse struct {
Query string `json:"query"`
Results []GeocodeResult `json:"results"`
}
var client = &http.Client{Timeout: 10 * time.Second}
func geocode(address, country string) (*Location, error) {
q := url.Values{}
q.Set("q", address)
if country != "" {
q.Set("country", country)
}
req, err := http.NewRequest("GET", baseURL+"/geocode?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
var out GeocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if len(out.Results) == 0 {
return nil, nil
}
return &out.Results[0].Location, nil
}
func main() {
loc, err := geocode("1 Apple Park Way, Cupertino, CA", "US")
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("%+v\n", loc)
}Запусти:
CSV2GEO_KEY=geo_live_xxx go run geocode.go
# &{Lat:37.33177 Lng:-122.03042}Несколько вещей, о которых стоит сказать. defer resp.Body.Close() не опционально — если забудешь, нижележащее TCP-соединение нельзя вернуть в pool, и под нагрузкой ты сольёшь file descriptor-ы. Используй один package-level http.Client вместо http.Get напрямую; у дефолтного клиента Timeout: 0, и зависший сервер заблокирует твою goroutine навсегда. Используй Authorization: Bearer вместо query-формы ?api_key=, чтобы ключи никогда не попадали в shell history или log-файлы.
Reverse geocoding
Та же форма, другие параметры. Передай lat и lng, получи в ответ адрес.
type ReverseResult struct {
FormattedAddress string `json:"formatted_address"`
Location Location `json:"location"`
Accuracy string `json:"accuracy"`
DistanceMeters float64 `json:"distance_meters,omitempty"`
}
type ReverseResponse struct {
Results []ReverseResult `json:"results"`
}
func reverse(lat, lng float64) (*ReverseResult, error) {
q := url.Values{}
q.Set("lat", fmt.Sprintf("%f", lat))
q.Set("lng", fmt.Sprintf("%f", lng))
req, err := http.NewRequest("GET", baseURL+"/reverse?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
var out ReverseResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if len(out.Results) == 0 {
return nil, nil
}
return &out.Results[0], nil
}Reverse-ответы включают поле distance_meters — насколько далеко найденный адрес сидит от входной координаты. Если ты делаешь reverse-geocoding GPS-пингов из delivery-приложения, всё, что выше ~50м, обычно означает, что плох GPS, а не геокодер. Ниже ~10м — ты смотришь на rooftop. Ground-truth distance — единственная честная метрика accuracy для reverse geocoding; всё остальное немного лжёт.
Bounded concurrency по-Go-шному
Неправильный способ геокодировать 10 000 адресов в Go — for _, a := range addrs { go geocode(a) }. Это запускает 10 000 unbounded goroutines, открывает 10 000 TCP-соединений, исчерпывает rate limit API на второй итерации и одновременно исчерпывает локальные file descriptor-ы. Goroutines дешёвые, но не бесплатные, а сеть не бесплатна никогда.
Правильный способ — bounded concurrency через semaphore-channel. Buffered channel из struct{} с ёмкостью N — канонический Go-семафор: отправка в него блокируется, как только N goroutines уже в полёте.
// pool.go
package main
import (
"fmt"
"sync"
)
func geocodeMany(addresses []string, concurrency int) []*Location {
sem := make(chan struct{}, concurrency)
out := make([]*Location, len(addresses))
var wg sync.WaitGroup
for i, addr := range addresses {
wg.Add(1)
sem <- struct{}{} // acquire
go func(i int, addr string) {
defer wg.Done()
defer func() { <-sem }() // release
loc, err := geocode(addr, "US")
if err != nil {
fmt.Println("err:", addr, err)
return
}
out[i] = loc
}(i, addr)
}
wg.Wait()
return out
}Три идиомы в этом коде оправдывают своё место. Во-первых, sem <- struct{}{} происходит *до* ключевого слова go, так что цикл for блокируется на throttle, а не плодит тысячи pending goroutines. Во-вторых, defer func() { <-sem }() освобождает слот даже если worker делает panic — цепочка defer, которую никогда нельзя пропускать. В-третьих, out[i] = loc пишет в *уникальный* index на goroutine, поэтому mutex не нужен; конкурентные записи в разные слоты slice-а в Go безопасны. Захват i и addr как параметров goroutine literal избегает loop-variable bug, который кусал каждого Go-разработчика до 1.22.
Полезное стартовое правило для concurrency: возьми per-minute rate limit твоего плана, раздели на 60 и целься примерно в столько же in-flight запросов. Free — 100/мин — начни с 4. Starter ($49, 1K/мин) — 16. Growth ($149, 5K/мин) — 64. Pro ($499, 10K/мин) — 128 и следи за headers. Полный разбор в concurrency tuning для geocoding.
Retry, backoff и context pattern
Три вещи, нужные каждому production geocoder: (1) обращаться с 429 и 5xx как с retriable, (2) уважать header Retry-After, когда сервер его шлёт, (3) ограничить количество попыток, чтобы навсегда мёртвый ключ не зацикливался. Добавь context.Context, чтобы caller мог отменить долгую цепочку retry, и у тебя готовая функция для реального сервиса.
// retry.go
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
type GeocodeError struct {
Status int
Message string
}
func (e *GeocodeError) Error() string {
return fmt.Sprintf("geocode: status=%d msg=%s", e.Status, e.Message)
}
func geocodeWithRetry(ctx context.Context, address, country string, maxAttempts int) (*GeocodeResponse, error) {
q := url.Values{}
q.Set("q", address)
if country != "" {
q.Set("country", country)
}
target := baseURL + "/geocode?" + q.Encode()
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
resp, err := client.Do(req)
if err != nil {
// Network errors are retriable.
lastErr = err
if attempt == maxAttempts {
return nil, fmt.Errorf("network error after %d attempts: %w", attempt, err)
}
if err := sleep(ctx, backoff(attempt)); err != nil {
return nil, err
}
continue
}
if resp.StatusCode < 300 {
defer resp.Body.Close()
var out GeocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
// Non-retriable: 4xx other than 429 means bad key, bad input, etc.
if resp.StatusCode != 429 && resp.StatusCode < 500 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, &GeocodeError{Status: resp.StatusCode, Message: string(body)}
}
// Retriable: 429 or 5xx. Honour Retry-After if present.
retryAfter := resp.Header.Get("Retry-After")
resp.Body.Close()
if attempt == maxAttempts {
return nil, &GeocodeError{Status: resp.StatusCode, Message: "exhausted retries"}
}
delay := backoff(attempt)
if retryAfter != "" {
if secs, err := strconv.ParseFloat(retryAfter, 64); err == nil {
delay = time.Duration(secs * float64(time.Second))
}
}
if err := sleep(ctx, delay); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("unreachable: lastErr=%v", lastErr)
}
func backoff(attempt int) time.Duration {
// 1, 2, 4, 8, 16... seconds + up to 1s jitter.
base := time.Duration(1<<(attempt-1)) * time.Second
jitter := time.Duration(rand.Float64() * float64(time.Second))
return base + jitter
}
func sleep(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}Несколько заметок по дизайну. time.NewTimer плюс select на ctx.Done() — канонический способ реализовать cancellable sleep — time.Sleep игнорирует context и оставит твою goroutine приколотой через deploy или SIGTERM. Jitter в backoff важен, когда много workers ретраят одновременно; без него каждый worker просыпается в один и тот же момент и переDDoS-ит сервер. См. exponential backoff: when to retry, when to stop про математику retry budgets и dead-letter queues.
Три rate-limit header-а возвращаются с каждым успешным ответом и стоят проверки в production:
| Header | Значение | |---|---| | X-RateLimit-Limit | Per-minute потолок твоего плана | | X-RateLimit-Remaining | Сколько осталось в этом окне | | X-RateLimit-Reset | Unix секунд до сброса окна | | Retry-After | Шлётся на 429 — секунд подождать перед retry |
Если Remaining падает ниже 10% от Limit, замедляйся добровольно — sleep, опусти семафор, переключись на batch. Дешевле, чем долбиться в 429-ки. Более глубокая теория, какой limiter algorithm производит эти headers — в token bucket vs leaky bucket vs sliding window.
Batch endpoint
Subscription tier-ы от Starter ($49/мес) и выше открывают batch endpoint, принимающий массив адресов в одном POST. Лимиты плана:
| Plan | Месячные строки | В минуту | Размер batch | |---|---|---|---| | Free | — (1K/день API, 100/день batch) | 100 | 100 | | Starter ($49) | 50 000 | 1 000 | 1 000 | | Growth ($149) | 250 000 | 5 000 | 5 000 | | Pro ($499) | 1 000 000 | 10 000 | 10 000 |
// batch.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type BatchRequest struct {
Addresses []string `json:"addresses"`
}
type BatchResponse struct {
Results []GeocodeResponse `json:"results"`
}
var batchClient = &http.Client{Timeout: 60 * time.Second}
func batchGeocode(ctx context.Context, addresses []string) (*BatchResponse, error) {
body, err := json.Marshal(BatchRequest{Addresses: addresses})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/geocode", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := batchClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("batch http %d", resp.StatusCode)
}
var out BatchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}Batch-ответы сохраняют порядок входа: позиция N в ответе всегда соответствует позиции N во входе. Не нужен round-trip с полем id. Обрати внимание на отдельный batchClient с timeout 60 секунд — batch на 1000 адресов на Growth занимает 10–15 секунд end-to-end, гораздо дольше, чем 10-секундный per-request клиент для singles.
Streaming CSV без OOM
Если у тебя на входе 100 000 строк, ты не хочешь читать их в [][]string и потом range по нему. Стримь. Pattern — worker pool: одна goroutine читает строки с диска и кладёт их в jobs channel, N worker goroutines потребляют channel и пишут результаты в output channel, а writer goroutine сливает output channel на диск.
// stream.go
package main
import (
"context"
"encoding/csv"
"fmt"
"io"
"os"
"sync"
)
type job struct {
ID string
Address string
}
type result struct {
ID string
Address string
Lat float64
Lng float64
Err string
}
const concurrency = 8
func streamGeocode(ctx context.Context, inPath, outPath string) error {
in, err := os.Open(inPath)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
reader := csv.NewReader(in)
writer := csv.NewWriter(out)
defer writer.Flush()
header, err := reader.Read()
if err != nil {
return err
}
idIdx, addrIdx := indexOf(header, "id"), indexOf(header, "address")
if idIdx < 0 || addrIdx < 0 {
return fmt.Errorf("csv must have id,address columns")
}
if err := writer.Write([]string{"id", "address", "lat", "lng", "error"}); err != nil {
return err
}
jobs := make(chan job, concurrency*2)
results := make(chan result, concurrency*2)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go worker(ctx, &wg, jobs, results)
}
// Writer goroutine drains results to disk.
done := make(chan struct{})
go func() {
for r := range results {
_ = writer.Write([]string{
r.ID, r.Address,
fmt.Sprintf("%f", r.Lat), fmt.Sprintf("%f", r.Lng), r.Err,
})
}
close(done)
}()
// Producer: read CSV row by row.
for {
row, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
close(jobs)
wg.Wait()
close(results)
<-done
return err
}
jobs <- job{ID: row[idIdx], Address: row[addrIdx]}
}
close(jobs)
wg.Wait()
close(results)
<-done
return nil
}
func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan job, results chan<- result) {
defer wg.Done()
for j := range jobs {
out, err := geocodeWithRetry(ctx, j.Address, "US", 5)
if err != nil {
results <- result{ID: j.ID, Address: j.Address, Err: err.Error()}
continue
}
if len(out.Results) == 0 {
results <- result{ID: j.ID, Address: j.Address, Err: "no_match"}
continue
}
top := out.Results[0]
if top.AccuracyScore < 0.7 {
results <- result{ID: j.ID, Address: j.Address, Err: "low_confidence"}
continue
}
results <- result{
ID: j.ID, Address: j.Address,
Lat: top.Location.Lat, Lng: top.Location.Lng,
}
}
}
func indexOf(row []string, name string) int {
for i, c := range row {
if c == name {
return i
}
}
return -1
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: stream input.csv output.csv")
os.Exit(1)
}
if err := streamGeocode(context.Background(), os.Args[1], os.Args[2]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}Три вещи, которые этот скрипт делает, а код новичка обычно нет. Во-первых, вход читается строка за строкой, никогда не загружается целиком. Во-вторых, channel-буфера маленькие (concurrency*2), так что producer естественно делает backpressure на workers — если все workers заняты, producer блокируется на channel send и ОС держит файл в основном на диске. В-третьих, ошибки записываются как столбец, а не валят весь job. Это разница между скриптом, который дотягивает 100K-строчный run, и скриптом, который надо рестартовать с вопросом где-он-остановился.
Типизированные ответы
Struct-ы в начале поста — это минимально жизнеспособная типизация. Если хочешь полное покрытие с optional полями, omitempty — твой друг: он держит JSON output чистым, когда поле отсутствует, и избегает zero-value путаницы при decoding ответа.
type Components struct {
HouseNumber string `json:"house_number,omitempty"`
Street string `json:"street,omitempty"`
City string `json:"city,omitempty"`
State string `json:"state,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
}
type Meta struct {
ResponseTimeMs int `json:"response_time_ms"`
Source string `json:"source"`
}
type FullGeocodeResult struct {
FormattedAddress string `json:"formatted_address"`
Location Location `json:"location"`
Accuracy string `json:"accuracy"`
AccuracyScore float64 `json:"accuracy_score"`
Components Components `json:"components"`
}
type FullGeocodeResponse struct {
Query string `json:"query"`
Results []FullGeocodeResult `json:"results"`
Meta Meta `json:"meta"`
}Определение Accuracy как typed alias (type Accuracy string плюс const declarations для каждого значения) даёт бесплатный domain check для switch-statement-ов. Компилятор не поймает typo в строковом литерале, но если ты switch-аешь на Accuracy, не обработанное значение хотя бы видно в diff.
Что кусает Go-разработчиков
Забыть defer resp.Body.Close(). За каждым http.Client.Do, вернувшим non-nil response, *обязан* идти Close на body, даже если ты body не читаешь. Go runtime не вернёт нижележащее TCP-соединение в connection pool, пока body не закрыт и не drained. Пропусти один раз — и под нагрузкой твой сервис сольёт file descriptor-ы, пока kernel не откажется давать новые сокеты.
Использование дефолтного http.Client. http.Get и компания используют http.DefaultClient, у которого Timeout: 0. Плохо ведущий себя сервер может оставить эту goroutine заблокированной на неопределённый срок. Всегда объявляй package-level клиент с явным timeout-ом или используй http.NewRequestWithContext с deadline. То же касается истории про p99 latency — если у тебя нет timeout, у тебя нет p99, у тебя хвост в бесконечность.
Deadlock-и unbuffered channel-а. Если worker читает из results, а writer goroutine ещё не запущена, send блокируется. Если producer читает из jobs, а workers нет, send блокируется. Буфер concurrency*2 в streaming-примере достаточен для forward progress; выбери буфер, при котором producer держится ~1 шаг впереди workers, не храня весь вход.
Наивные паттерны WaitGroup + channel. Распространённый баг: закрытие results channel-а до того, как workers закончили, и writer goroutine видит закрытый channel и выходит с partial output. Фикс — явный порядок в конце streamGeocode: close(jobs) первым (workers выйдут, увидев закрытый jobs channel), затем wg.Wait() чтобы убедиться, что все workers готовы, затем close(results), затем <-done чтобы дождаться writer-а. Перепутай порядок — и увидишь баги missing-rows, воспроизводящиеся только на длинных входах.
Часто задаваемые вопросы
Нужен ли SDK?
Нет. Стандартная библиотека — net/http, encoding/json, context, time — покрывает всё в этом посте. SDK, который большинство команд в итоге пишут, — это тонкий wrapper вокруг geocodeWithRetry, набор struct-ов в стиле Pydantic и worker-pool helper. Поддерживать ~150 строк своего кода дешевле, чем отслеживать чужой release cadence и vendored breaking change каждые полгода.
Стоит ли обращаться с ошибками как со значениями?
Да — это идиоматичный Go. Тип *GeocodeError в retry-функции несёт HTTP status как типизированное поле, чтобы caller-ы могли switch по нему без парсинга строк. Оборачивай сетевые ошибки через fmt.Errorf("...: %w", err), чтобы errors.Is и errors.As продолжали работать через call stack. Избегай panic для всего, что пересекает API boundary; оставь panic для настоящих программистских багов.
net/http против fasthttp?
Оставайся на net/http для geocoding-клиентов. fasthttp быстрее на уровне microbenchmark, но имеет другой API, не поддерживает HTTP/2 и переиспользует request/response-объекты между goroutines способами, которые кусают, когда отдаёшь их worker pool. Bottleneck в geocoding-pipeline — сетевой round trip, не HTTP-парсер; fasthttp сэкономит наносекунды, пока API занимает сотни миллисекунд.
Как тюнить concurrency?
Возьми per-minute rate limit твоего плана, раздели на 60 и целься примерно в столько же конкурентных запросов. 1000/мин ÷ 60 ≈ 17, так что pool size 16 — безопасный default на Starter. Источник правды — header X-RateLimit-Remaining; логируй его на каждом успешном ответе и следи, не падает ли он быстрее 1 на запрос. Оптимальный concurrency — это кривая, и колено обычно около (rate_limit / 60) * 1.5. Полная методология в concurrency tuning для geocoding.
Как отличить no-match от успешного match-а с низкой confidence?
results — пустой slice при no-match. На low-confidence match results[0] существует, но accuracy — это "postcode" или "place" вместо "houseNumber" или "street", и accuracy_score сильно ниже 1.0. Разумный default threshold — 0.7; ниже — считай как no-match. Более строгие pipeline-ы (insurance risk scoring, healthcare patient mapping) используют 0.95 и требуют accuracy == "houseNumber". Полная картина — в geocoding confidence scores explained.
Работает ли это для не-US адресов?
Да. Передай правильный ISO alpha-2 в параметре country — DE для Германии, GB для UK, BR для Бразилии, JP для Японии. Покрытие сегодня охватывает 39 стран, включая полный top 10 по числу адресов: USA (121M адресов), Бразилия (90M), Мексика (30M), Франция (26M), Италия (26M), и остальные. Страница API перечисляет per-country counts.
Использовать single запросы или batch endpoint?
Batch, когда есть ≥100 адресов за раз и план позволяет (Starter+). Один POST дешевле для обеих сторон, чем 100 GET-ов, и экономия latency примерно avg_per_request_ms * count / concurrency. Singles проще для streaming-нагрузок, где адреса прибывают со временем, и singles — единственная опция на free tier. Полный анализ компромиссов — в batch vs realtime geocoding.
Куда дальше
Полный reference API — на csv2geo.com/api. Если предпочитаешь работать на другом языке, Node.js geocoding tutorial следует той же структуре с fetch и p-limit, а Python geocoding tutorial покрывает httpx и asyncio. Для более глубоких погружений в паттерны, которые этот пост затрагивает: rate limiting algorithms compared, concurrency tuning для geocoding, и почему p99 latency важнее среднего.
Если найдёшь edge case, наткнёшься на форму ответа, которую этот пост не покрывает, или хочешь feedback на Go-pipeline, который строишь, — форма обратной связи на сайте доходит до человека, который её читает. Bug report-ы с curl (или go run) воспроизведениями фиксятся быстро.
I.A. / CSV2GEO Creator
Связанные статьи
- Node.js Geocoding API Tutorial: Concurrency, Retries и CSV Streaming
- Python Geocoding API Tutorial: Async, Retries и Pipeline на 100K строк
- Concurrency Tuning для Geocoding: найди свой Sweet Spot
- Rate Limiting Geocoding-Pipeline: Token Bucket vs Leaky Bucket vs Sliding Window
- p99 Latency в Geocoding: почему среднее лжёт
- Monorepo-паттерны для multi-language Geocoding-клиентов
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →