Tutorial de Geocoding em Go: Goroutines, Concorrência Limitada e Backoff

Tutorial Go de produção para geocoding: pools de goroutines, concorrência via semáforo, exponential backoff em 429s. Compila e roda.

| May 05, 2026
Tutorial de Geocoding em Go: Goroutines, Concorrência Limitada e Backoff

Go é a linguagem para escolher quando você quer concorrência sem cerimônia de async/await. Goroutines são stacks de 2 KB, channels são pipes tipados, e a biblioteca padrão net/http já é boa o suficiente para produção desde o primeiro dia. Não há event loop para aprender, nenhuma palavra-chave await para espalhar por toda função, e nenhum problema de função colorida. Você escreve código em linha reta, e o paraleliza com go e um channel-semáforo.

Este é um tutorial Go funcional para geocoding de endereços via REST API. Cada snippet foi escrito num arquivo .go, compilado com go build, e executado contra a API real numa instalação limpa de Go 1.22 antes de publicar. Não há SDK, biblioteca HTTP de terceiros nem framework — apenas net/http, encoding/json, encoding/csv, e context. Ao final você terá um programa que faz streaming de um CSV de qualquer tamanho, geocoda suas linhas com um pool configurável de goroutines, refaz tentativas em 429s e 5xx com exponential backoff com jitter que respeita Retry-After, e escreve um CSV {lat, lng, error} sem nunca manter o arquivo de entrada em memória. Cerca de 80 linhas de Go.

O endpoint usado em todo o tutorial é csv2geo.com/api/v1. O free tier oferece 1.000 requisições forward/reverse por dia mais 100 linhas de batch por dia, sem cartão de crédito. Faça login e pegue uma chave em /api-keys para acompanhar.

O endpoint

Dois endpoints cobrem quase todo trabalho de geocoding do mundo real. Forward transforma uma string de endereço em coordenadas. Reverse transforma coordenadas em endereço. Ambos aceitam GET (single) ou 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

O formato da resposta para uma única requisição forward fica assim. Esta é saída real, não um exemplo de documentação.

{
  "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" }
}

Dois campos importam mais. results[0].location é seu {lat, lng}. results[0].accuracy é o nível do match — "houseNumber" é rooftop, "street" é centróide da rua, "place" é match de POI, "postcode" é centróide de código postal. O accuracy_score numérico (0.0–1.0) dá um threshold mais fino; veja scores de confiança em geocoding explicados para escolher um.

Primeira requisição

net/http mais encoding/json é tudo que você precisa. A primeira versão retorna apenas as coordenadas para podermos verificar o formato wire de ponta a ponta.

// 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)
}

Execute:

CSV2GEO_KEY=geo_live_xxx go run geocode.go
# &{Lat:37.33177 Lng:-122.03042}

Algumas coisas valem ser apontadas. defer resp.Body.Close() não é opcional — se você esquecer, a conexão TCP subjacente não pode ser devolvida ao pool e você vai vazar file descriptors sob carga. Use um único http.Client no nível do pacote em vez de http.Get diretamente; o cliente padrão tem Timeout: 0 — o que significa que um servidor travado pode bloquear sua goroutine para sempre. Use Authorization: Bearer em vez do parâmetro ?api_key= para que chaves nunca terminem em histórico de shell ou arquivos de log.

Reverse geocoding

Mesmo formato, parâmetros diferentes. Passe lat e lng, receba de volta um endereço.

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
}

Respostas reverse incluem um campo distance_meters — quão longe o endereço encontrado está da coordenada de entrada. Se você está fazendo reverse-geocoding de pings GPS de um app de delivery, qualquer coisa acima de ~50m geralmente significa que o GPS estava ruim, não o geocoder. Abaixo de ~10m você está olhando para o rooftop. A distância ground-truth é a única métrica honesta de acurácia para reverse geocoding; todo o resto mente um pouco.

Concorrência limitada do jeito Go

O jeito errado de geocodar 10.000 endereços em Go é for _, a := range addrs { go geocode(a) }. Isso dispara 10.000 goroutines sem limite, abre 10.000 conexões TCP, exaure o rate limit da API na segunda iteração e exaure seus file descriptors locais ao mesmo tempo. Goroutines são baratas, mas não são de graça, e a rede nunca é de graça.

O jeito certo é concorrência limitada com um channel-semáforo. Um channel buferizado de struct{} com capacidade N é o semáforo canônico em Go — enviar nele bloqueia assim que N goroutines já estão em voo.

// 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
}

Três idiomas neste código justificam seu lugar. Primeiro, sem <- struct{}{} acontece *antes* da palavra-chave go, então o loop for bloqueia no throttle em vez de agendar milhares de goroutines pendentes. Segundo, defer func() { <-sem }() libera o slot mesmo se o worker faz panic — uma cadeia de defer que você nunca deve pular. Terceiro, out[i] = loc escreve num índice *único* por goroutine, então nenhum mutex é necessário; escritas concorrentes em slots diferentes de uma slice são seguras em Go. Capturar i e addr como parâmetros do literal de goroutine evita o bug da loop-variable que mordeu todo dev Go antes de 1.22.

Uma regra inicial útil para concurrency: pegue o rate limit por minuto do seu plano, divida por 60, e mire em aproximadamente esse número de requisições in-flight. Free é 100/min — comece em 4. Starter ($49, 1K/min) — 16. Growth ($149, 5K/min) — 64. Pro ($499, 10K/min) — 128 e fique de olho nos headers. O detalhamento completo está em concurrency tuning para geocoding.

Retry, backoff e o pattern de context

Três coisas que todo geocoder de produção precisa: (1) tratar 429 e 5xx como retriáveis, (2) honrar o header Retry-After quando o servidor manda um, (3) limitar tentativas para que uma chave permanentemente morta não fique em loop para sempre. Adicione context.Context para que o caller possa cancelar uma cadeia de retry longa, e você tem uma função segura para colocar num serviço real.

// 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
    }
}

Algumas notas sobre o design. time.NewTimer mais um select em ctx.Done() é a forma canônica de implementar um sleep cancelável — time.Sleep ignora o context e vai manter sua goroutine presa durante um deploy ou um SIGTERM. O jitter em backoff importa quando muitos workers fazem retry simultaneamente; sem ele cada worker acorda no mesmo instante e re-DDoSa o servidor. Veja exponential backoff: quando refazer, quando parar para a matemática sobre orçamentos de retry e dead-letter queues.

Três headers de rate-limit voltam em toda resposta bem-sucedida e valem ser checados em produção:

| Header | Significado | |---|---| | X-RateLimit-Limit | Teto por minuto do seu plano | | X-RateLimit-Remaining | O que sobrou nesta janela | | X-RateLimit-Reset | Segundos Unix até a janela resetar | | Retry-After | Enviado em 429s — segundos a esperar antes de refazer |

Se Remaining cai abaixo de 10% de Limit, desacelere voluntariamente — sleep, baixe o semáforo, troque para batch. Mais barato que esmurrar 429s. A teoria mais profunda de qual algoritmo de limiter produz esses headers está em token bucket vs leaky bucket vs sliding window.

O endpoint de batch

Tiers de assinatura do Starter ($49/mês) para cima expõem um endpoint de batch que aceita um array de endereços num único POST. Limites do plano:

| Plano | Linhas mensais | Por minuto | Tamanho do batch | |---|---|---|---| | Free | — (1K/dia API, 100/dia 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
}

Respostas de batch preservam a ordem da entrada: a posição N da resposta sempre corresponde à posição N da entrada. Sem necessidade de transmitir um campo id. Note o batchClient dedicado com timeout de 60 segundos — um batch de 1.000 endereços no Growth leva 10–15 segundos de ponta a ponta, bem mais que o cliente de 10 segundos por requisição usado para singles.

Streaming de CSV sem OOM

Se seu arquivo de entrada tem 100.000 linhas, você não quer lê-lo num [][]string e fazer range sobre ele. Faça streaming. O pattern é o worker pool: uma goroutine lê linhas do disco e empurra elas num channel de jobs, N worker goroutines consomem o channel e escrevem resultados num channel de saída, e uma writer goroutine drena o channel de saída para disco.

// 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)
    }
}

Três coisas que este script faz que código de iniciante normalmente não faz. Primeiro, a entrada é lida linha por linha, nunca carregada inteira. Segundo, os buffers dos channels são pequenos (concurrency*2) então o producer naturalmente faz backpressure nos workers — se todos os workers estão ocupados, o producer bloqueia no envio do channel e o SO mantém o arquivo majoritariamente em disco. Terceiro, erros são gravados como uma coluna em vez de derrubar o job inteiro. Essa é a diferença entre um script que termina um run de 100K linhas e um script que precisa ser reiniciado com a pergunta onde-parou.

Respostas tipadas

Os structs no topo deste post são a tipagem mínima viável. Se você quer cobertura completa com campos opcionais, omitempty é seu amigo — mantém a saída JSON limpa quando um campo está ausente e evita confusão de zero-value ao decodar uma resposta.

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"`
}

Definir Accuracy como um alias tipado (type Accuracy string mais const declarations para cada valor) te dá um domain check grátis para switch statements. O compilador não vai pegar um typo num literal de string, mas se você der switch em Accuracy, um valor não tratado é pelo menos visível no diff.

Coisas que mordem devs Go

Esquecer defer resp.Body.Close(). Todo http.Client.Do que retorna uma resposta não-nil *deve* ser seguido de um Close no body, mesmo que você nunca leia o body. O runtime do Go não vai devolver a conexão TCP subjacente ao pool de conexões até que o body seja fechado e drenado. Pule uma vez e seu serviço vai vazar file descriptors sob carga até o kernel recusar novos sockets.

Usar o http.Client padrão. http.Get e companhia usam http.DefaultClient, que tem Timeout: 0. Um servidor mal comportado pode deixar essa goroutine bloqueada indefinidamente. Sempre declare um cliente no nível do pacote com timeout explícito, ou use http.NewRequestWithContext com um deadline. O mesmo vale para a história de p99 latency — se você não tem timeout, você não tem p99, você tem uma cauda que vai até infinito.

Deadlocks de channel não-buferizado. Se um worker lê de results e a writer goroutine não começou, o envio bloqueia. Se o producer lê de jobs e nenhum worker está vivo, o envio bloqueia. O buffer concurrency*2 no exemplo de streaming é suficiente para forward progress; escolha um buffer que deixe o producer ficar ~1 passo à frente dos workers sem armazenar a entrada inteira.

Patterns ingênuos de WaitGroup + channel. Bug comum: fechar o channel de results antes dos workers terminarem, então a writer goroutine vê um channel fechado e sai com saída parcial. A correção é a ordenação explícita no fim de streamGeocode: close(jobs) primeiro (workers saem ao ver o channel jobs fechado), depois wg.Wait() para confirmar que todos os workers terminaram, depois close(results), depois <-done para esperar o writer. Erre essa ordem e você verá bugs de linhas faltando que só reproduzem em entradas longas.

Perguntas frequentes

Preciso de um SDK?

Não. A biblioteca padrão — net/http, encoding/json, context, time — cobre tudo neste post. O SDK que a maioria dos times acaba escrevendo é um wrapper fino em volta de geocodeWithRetry, um conjunto de structs equivalente a Pydantic, e um helper de worker pool. Manter ~150 linhas do seu próprio código é mais barato que rastrear o ritmo de release de terceiros e uma quebra vendorada a cada seis meses.

Devo tratar erros como valores?

Sim — esse é o jeito idiomático em Go. O tipo *GeocodeError na função de retry carrega o status HTTP como campo tipado para que callers possam dar switch nele sem parsear strings. Embrulhe erros de rede com fmt.Errorf("...: %w", err) para que errors.Is e errors.As continuem funcionando pela call stack. Evite panic para qualquer coisa que cruza um boundary de API; reserve panics para bugs genuínos do programador.

net/http versus fasthttp?

Fique com net/http para clientes de geocoding. fasthttp é mais rápido em microbenchmark mas tem uma API diferente, não suporta HTTP/2, e reusa objetos request/response entre goroutines de jeitos que mordem quando você os entrega a um worker pool. O bottleneck num pipeline de geocoding é o round trip de rede, não o parser HTTP — fasthttp vai te economizar nanossegundos enquanto a API leva centenas de milissegundos.

Como faço tuning de concurrency?

Pegue o rate limit por minuto do seu plano, divida por 60, e mire em aproximadamente esse número de requisições concorrentes. 1.000/min ÷ 60 ≈ 17, então um pool de 16 é um default seguro no Starter. A fonte de verdade é o header X-RateLimit-Remaining — logue em toda resposta bem-sucedida e fique de olho se ele cai mais rápido que 1 por requisição. A concurrency ótima é uma curva, e o joelho geralmente fica perto de (rate_limit / 60) * 1.5. Metodologia completa em concurrency tuning para geocoding.

Como detecto um no-match versus um match bem-sucedido de baixa confiança?

results é uma slice vazia em no-match. Num match de baixa confiança results[0] existe mas accuracy é "postcode" ou "place" em vez de "houseNumber" ou "street", e accuracy_score está bem abaixo de 1.0. Um threshold default razoável é 0.7; abaixo disso, trate como no-match. Pipelines mais estritos (scoring de risco em seguros, mapeamento de pacientes em saúde) usam 0.95 e exigem accuracy == "houseNumber". O quadro completo está em scores de confiança em geocoding explicados.

Isso funciona para endereços fora dos EUA?

Sim. Passe o ISO alpha-2 correto no parâmetro countryDE para Alemanha, GB para Reino Unido, BR para Brasil, JP para Japão. Cobertura abrange 39 países hoje, incluindo o top 10 completo por contagem de endereços: EUA (121M endereços), Brasil (90M), México (30M), França (26M), Itália (26M), e o resto. A página da API lista contagens por país.

Devo usar requisições single ou o endpoint de batch?

Batch quando você tem ≥100 endereços para fazer de uma vez e seu plano permite (Starter+). Um POST é mais barato para ambos os lados que 100 GETs, e a economia de latência é aproximadamente avg_per_request_ms * count / concurrency. Singles são mais simples para workloads streaming onde endereços chegam ao longo do tempo, e singles são a única opção no free tier. A análise completa de tradeoff está em batch vs realtime geocoding.

Para onde ir a partir daqui

A referência completa da API está em csv2geo.com/api. Se você prefere trabalhar em outra linguagem, o tutorial de geocoding em Node.js segue a mesma estrutura com fetch e p-limit, e o tutorial de geocoding em Python cobre httpx e asyncio. Para mergulhos mais profundos nos patterns que este post toca: algoritmos de rate limiting comparados, concurrency tuning para geocoding, e por que p99 latency importa mais que a média.

Se você encontrar um edge case, bater num formato de resposta que este post não cobre, ou quiser feedback num pipeline Go que está construindo, o formulário de contato no site chega numa pessoa que lê. Bug reports com curl (ou go run) reproduções são consertados rapidamente.

I.A. / CSV2GEO Creator

Artigos relacionados

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 →