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 е 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) уважавай 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-ът

Subscription tier-ите от Starter ($49/мес) нагоре излагат batch endpoint, който приема масив от адреси в един POST. Лимити на плана:

| План | Месечни редове | Per-minute | Размер на 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 с 60-секунден timeout — 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 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 заявки. 1000/мин ÷ 60 ≈ 17, така че pool size 16 е безопасен default на Starter. Източникът на истина е 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 адреса наведнъж и планът ти позволява (Starter+). Един POST е по-евтин и за двете страни от 100 GET-а, а икономията на latency е приблизително avg_per_request_ms * count / concurrency. Singles са по-прости за streaming workloads, при които адресите пристигат с времето, и singles са единствената опция на free tier. Пълният 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

Свързани статии

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 →