Go Geocoding Tutorial: Goroutines, Bounded Concurrency и Backoff

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

| May 05, 2026
Go Geocoding Tutorial: Goroutines, Bounded Concurrency и Backoff

Go е езикът, към който посягаш, когато искаш concurrency без церемонията на async/await. Goroutines са stack-ове по 2 KB, 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 превръща string-адрес в координати. 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 е всичко, което ти трябва. Първата версия връща само координатите, за да можем да проверим 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 директно; default клиентът има 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 — колко далеч matched адресът сяда от входната координата. Ако правиш reverse-geocoding на GPS пинговете на delivery приложение, всичко над ~50м обикновено значи, че GPS fix-ът е бил лош, не геокодерът. Под ~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 вече са in-flight.

// 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 loop-ът блокира на throttle-а вместо да scheduler-ва хиляди pending goroutines. Второ, defer func() { <-sem }() освобождава slot-а дори ако worker-ът panic-не — верига от defer, която никога не бива да пропускаш. Трето, out[i] = loc пише на *уникален* index на goroutine, така че няма нужда от mutex; concurrent записи в различни slot-ове на slice са безопасни в Go. Capture-ването на i и addr като параметри на goroutine literal-а избягва loop-variable бъга, който е захапвал всеки 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) уважавай Retry-After header-а, когато сървърът го праща, (3) ограничи опитите, за да не зацикля завинаги мъртъв ключ. Добави context.Context, за да може caller-ът да отмени дълга retry верига, и имаш функция, безопасна за пускане в реален service.

// 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 се събужда в същия момент и прави re-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 | Какво ти остава в този window | | X-RateLimit-Reset | Unix секунди до reset на window-а | | 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. Лимити на плана:

| План | Месечни кредити | Per-minute | Размер на 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 с 60-секунден timeout — 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 drain-ва 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-row 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 statements. Компилаторът няма да хване typo в string литерал, но ако 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-ът не откаже нови sockets.

Използване на default 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 не е стартирала, изпращането блокира. Ако producer чете от jobs, а няма жив worker, изпращането блокира. Буферът 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 на трета страна и vendored breaking change на всеки шест месеца.

Да третирам ли грешките като стойности?

Да — това е идиоматично Go. Типът *GeocodeError в retry функцията носи HTTP status като типизирано поле, така че caller-ите могат да правят switch по него без парсване на string-ове. Опаковай мрежови грешки с fmt.Errorf("...: %w", err), за да продължат errors.Is и errors.As да работят през call stack-а. Избягвай panic за всичко, което пресича API boundary; запази panic-ите за истински programmer бъгове.

net/http срещу fasthttp?

Остани с net/http за geocoding клиенти. fasthttp е по-бърз на ниво microbenchmark, но има различен API, не поддържа HTTP/2 и преизползва request/response обекти между goroutines по начини, които захапват, когато ги подадеш на worker pool. Bottleneck-ът на geocoding pipeline е network round trip-а, не HTTP parser-ът — fasthttp ще ти спести наносекунди, докато API отнема стотици милисекунди.

Как да тюнвам concurrency?

Вземи per-minute rate limit на твоя план, раздели на 60 и цели приблизително толкова concurrent заявки. 1500/мин ÷ 60 = 25, така че pool size 24 е безопасен default на платените API планове. Източникът на истина е header-ът X-RateLimit-Remaining — логвай го на всеки успешен отговор и гледай дали не пада по-бързо от 1 на заявка. Оптималната concurrency е крива, и коляното обикновено е близо до (rate_limit / 60) * 1.5. Пълна методология в concurrency tuning за geocoding.

Как да отлича no-match от успешен low-confidence match?

results е празен slice при no-match. На low-confidence match results[0] съществува, но accuracy е "postcode" или "place" вместо "houseNumber" или "street", и accuracy_score е доста под 1.0. Разумен default threshold е 0.7; под него — третирай като no-match. По-стриктни pipelines (insurance risk scoring, healthcare patient mapping) използват 0.95 и изискват accuracy == "houseNumber". Пълната картина е в geocoding confidence scores explained.

Работи ли това за non-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 workloads, при които адресите пристигат с времето, а batch е достъпен и на free tier, в рамките на неговите 3 000 заявки/ден. Пълният tradeoff анализ е в 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 →