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 — 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 в параметре countryDE для Германии, 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

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

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 →