Go Geocoding Tutorial: Goroutines, ограниченная конкурентность и backoff

Production Go tutorial по geocoding: goroutine pools, semaphore-bounded concurrency, exponential backoff на 429. Компилируется и работает.

| May 05, 2026
Go Geocoding Tutorial: Goroutines, ограниченная конкурентность и backoff

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 — 300/мин — начни с 5. API 10 … API 100 ($54–$294) — 1 500/мин — начни с 25. API 250 ($604) — 1 800/мин — начни с 30, и следи за 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

Все tier-ы, включая Free, открывают batch endpoint, принимающий массив адресов в одном POST. Лимиты плана:

| Plan | Месячные кредиты | В минуту | Размер batch | |---|---|---|---| | Free | 3 000/день | 300 | 1 000 | | API 10 ($54) | 300 000 | 1 500 | 1 000 | | API 25 ($104) | 750 000 | 1 500 | 1 000 | | API 50 ($174) | 1 500 000 | 1 500 | 1 000 | | API 100 ($294) | 3 000 000 | 1 500 | 1 000 | | API 250 ($604) | 7 500 000 | 1 800 | 1 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 адресов занимает 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 и целься примерно в столько же конкурентных запросов. 1500/мин ÷ 60 = 25, так что pool size 24 — безопасный default на платных API-тарифах. Источник правды — 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 в параметре countryDE для Германии, GB для UK, BR для Бразилии, JP для Японии. Покрытие сегодня охватывает 39 стран, включая полный top 10 по числу адресов: USA (121M адресов), Бразилия (90M), Мексика (30M), Франция (26M), Италия (26M), и остальные. Страница API перечисляет per-country counts.

Использовать single запросы или batch endpoint?

Batch, когда есть ≥100 адресов за раз — все tier-ы, включая Free, это позволяют. Один POST дешевле для обеих сторон, чем 100 GET-ов, и экономия latency примерно avg_per_request_ms * count / concurrency. Singles проще для streaming-нагрузок, где адреса прибывают со временем, а batch доступен и на free tier, в пределах его 3 000 вызовов/день. Полный анализ компромиссов — в 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

Связанные статьи

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 →