Tutoriel Go pour le Geocoding : Goroutines, Concurrence Bornée et Backoff

Tutoriel Go de production pour le geocoding : pools de goroutines, concurrence par sémaphore, exponential backoff sur 429. Compile et tourne.

| May 05, 2026
Tutoriel Go pour le Geocoding : Goroutines, Concurrence Bornée et Backoff

Go est le langage à choisir quand on veut de la concurrence sans la cérémonie d'async/await. Les goroutines sont des stacks de 2 Ko, les channels sont des pipes typés, et la bibliothèque standard net/http est déjà suffisante pour la production dès le premier jour. Pas d'event loop à apprendre, aucun mot-clé await à parsemer dans chaque fonction, et aucun problème de fonction colorée. Vous écrivez du code en ligne droite, et vous le distribuez avec go et un channel-sémaphore.

Ceci est un tutoriel Go fonctionnel pour le geocoding d'adresses via REST API. Chaque snippet a été écrit dans un fichier .go, compilé avec go build, et exécuté contre l'API en production sur une installation Go 1.22 propre avant publication. Pas de SDK, pas de bibliothèque HTTP tierce, pas de framework — juste net/http, encoding/json, encoding/csv, et context. À la fin vous aurez un programme qui fait du streaming d'un CSV de n'importe quelle taille, géocode ses lignes avec un pool configurable de goroutines, retente les 429 et 5xx avec exponential backoff jittered qui respecte Retry-After, et écrit un CSV {lat, lng, error} sans jamais garder le fichier d'entrée en mémoire. Environ 80 lignes de Go.

L'endpoint utilisé tout au long est csv2geo.com/api/v1. Le free tier offre 1 000 requêtes forward/reverse par jour plus 100 lignes de batch par jour, sans carte de crédit. Connectez-vous et récupérez une clé sur /api-keys pour suivre.

L'endpoint

Deux endpoints couvrent presque tous les jobs de geocoding du monde réel. Forward transforme une chaîne d'adresse en coordonnées. Reverse transforme des coordonnées en adresse. Les deux acceptent 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

La forme de la réponse pour une seule requête forward ressemble à ceci. C'est de la sortie réelle, pas un exemple de documentation.

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

Deux champs comptent le plus. results[0].location est votre {lat, lng}. results[0].accuracy est le niveau du match — "houseNumber" est rooftop, "street" est centroïde de rue, "place" est un match de POI, "postcode" est centroïde de code postal. Le accuracy_score numérique (0.0–1.0) donne un seuil plus fin ; voir scores de confiance en geocoding expliqués pour en choisir un.

Première requête

net/http plus encoding/json est tout ce qu'il vous faut. La première version retourne juste les coordonnées pour qu'on puisse vérifier le format wire de bout en bout.

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

Lancez-le :

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

Quelques points à souligner. defer resp.Body.Close() n'est pas optionnel — si vous l'oubliez, la connexion TCP sous-jacente ne peut pas être rendue au pool et vous fuirez des file descriptors sous charge. Utilisez un seul http.Client au niveau du package plutôt que http.Get directement ; le client par défaut a Timeout: 0, ce qui signifie qu'un serveur figé peut bloquer votre goroutine pour toujours. Utilisez Authorization: Bearer plutôt que la forme ?api_key= en query pour que les clés ne se retrouvent jamais dans l'historique du shell ou les fichiers de log.

Reverse geocoding

Même forme, paramètres différents. Passez lat et lng, récupérez une adresse en retour.

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
}

Les réponses reverse incluent un champ distance_meters — à quelle distance l'adresse correspondante se trouve de la coordonnée d'entrée. Si vous faites du reverse-geocoding sur des pings GPS d'une app de delivery, tout au-dessus de ~50m signifie en général que le fix GPS était mauvais, pas le geocoder. En dessous de ~10m vous regardez le rooftop. La distance ground-truth est la seule métrique honnête d'accuracy pour le reverse geocoding ; tout le reste ment un peu.

Concurrence bornée à la manière Go

La mauvaise façon de géocoder 10 000 adresses en Go est for _, a := range addrs { go geocode(a) }. Ça lance 10 000 goroutines sans limite, ouvre 10 000 connexions TCP, épuise le rate limit de l'API à la deuxième itération, et épuise vos file descriptors locaux en même temps. Les goroutines sont bon marché, mais elles ne sont pas gratuites, et le réseau n'est jamais gratuit.

La bonne façon est la concurrence bornée avec un channel-sémaphore. Un channel buffered de struct{} plafonné à N est le sémaphore canonique en Go — y envoyer bloque dès que N goroutines sont déjà en vol.

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

Trois idiomes dans ce code méritent leur place. Premièrement, sem <- struct{}{} arrive *avant* le mot-clé go, donc la boucle for bloque au throttle plutôt que de programmer des milliers de goroutines en attente. Deuxièmement, defer func() { <-sem }() libère le slot même si le worker fait panic — une chaîne de defer que vous ne devriez jamais sauter. Troisièmement, out[i] = loc écrit dans un index *unique* par goroutine, donc aucun mutex n'est nécessaire ; les écritures concurrentes vers des slots différents d'un slice sont sûres en Go. Capturer i et addr comme paramètres du littéral de goroutine évite le bug de loop-variable qui a mordu chaque dev Go avant 1.22.

Une règle de départ utile pour concurrency : prenez le rate limit par minute de votre plan, divisez par 60, et visez environ ce nombre de requêtes in-flight. Free c'est 100/min — commencez à 4. Starter ($49, 1K/min) — 16. Growth ($149, 5K/min) — 64. Pro ($499, 10K/min) — 128 et surveillez les headers. Le détail complet est dans concurrency tuning pour le geocoding.

Retry, backoff et le pattern context

Trois choses dont tout geocoder de production a besoin : (1) traiter 429 et 5xx comme retriables, (2) honorer le header Retry-After quand le serveur en envoie un, (3) plafonner les tentatives pour qu'une clé définitivement morte ne tourne pas en boucle pour toujours. Ajoutez context.Context pour que l'appelant puisse annuler une longue chaîne de retry, et vous avez une fonction sûre à intégrer dans un vrai 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
    }
}

Quelques notes sur le design. time.NewTimer plus un select sur ctx.Done() est la façon canonique d'implémenter un sleep annulable — time.Sleep ignore le context et gardera votre goroutine épinglée pendant un deploy ou un SIGTERM. Le jitter dans backoff importe quand beaucoup de workers retentent simultanément ; sans lui, chaque worker se réveille au même instant et re-DDoS le serveur. Voir exponential backoff : quand retenter, quand arrêter pour les maths sur les budgets de retry et les dead-letter queues.

Trois headers de rate-limit reviennent sur chaque réponse réussie et valent la peine d'être vérifiés en production :

| Header | Signification | |---|---| | X-RateLimit-Limit | Plafond par minute de votre plan | | X-RateLimit-Remaining | Ce qu'il vous reste dans cette fenêtre | | X-RateLimit-Reset | Secondes Unix jusqu'au reset de la fenêtre | | Retry-After | Envoyé sur 429s — secondes à attendre avant de retenter |

Si Remaining tombe sous 10% de Limit, ralentissez volontairement — sleep, baissez le sémaphore, passez en batch. Moins cher que de fracasser des 429. La théorie plus profonde de quel algorithme de limiter produit ces headers est dans token bucket vs leaky bucket vs sliding window.

L'endpoint batch

Les tiers d'abonnement à partir de Starter ($49/mois) exposent un endpoint batch qui prend un tableau d'adresses dans un seul POST. Plafonds du plan :

| Plan | Lignes mensuelles | Par minute | Taille de batch | |---|---|---|---| | Free | — (1K/jour API, 100/jour 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
}

Les réponses batch préservent l'ordre d'entrée : la position N de la réponse correspond toujours à la position N de l'entrée. Pas besoin de faire un aller-retour avec un champ id. Notez le batchClient dédié avec timeout de 60 secondes — un batch de 1 000 adresses sur Growth prend 10–15 secondes de bout en bout, bien plus long que le client de 10 secondes par requête utilisé pour les singles.

Streaming CSV sans OOM

Si votre fichier d'entrée fait 100 000 lignes, vous ne voulez pas le lire dans un [][]string et faire un range dessus. Streamez-le. Le pattern est le worker pool : une goroutine lit les lignes du disque et les pousse sur un channel de jobs, N worker goroutines consomment le channel et écrivent les résultats sur un channel de sortie, et une writer goroutine draine le channel de sortie vers le disque.

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

Trois choses que ce script fait que le code de débutant ne fait habituellement pas. Premièrement, l'entrée est lue ligne par ligne, jamais chargée entièrement. Deuxièmement, les buffers des channels sont petits (concurrency*2) donc le producer fait naturellement de la backpressure sur les workers — si tous les workers sont occupés, le producer bloque sur l'envoi du channel et l'OS garde le fichier majoritairement sur disque. Troisièmement, les erreurs sont enregistrées comme une colonne plutôt que de planter le job entier. C'est la différence entre un script qui termine un run de 100K lignes et un script qu'il faut redémarrer avec la question où-il-s'est-arrêté.

Réponses typées

Les structs en haut de ce post sont le typage minimum viable. Si vous voulez la couverture complète avec des champs optionnels, omitempty est votre ami — il garde la sortie JSON propre quand un champ est absent et évite la confusion zero-value lors du décodage d'une réponse.

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

Définir Accuracy comme alias typé (type Accuracy string plus des déclarations const pour chaque valeur) vous donne un domain check gratuit pour les switch statements. Le compilateur n'attrapera pas une typo sur un littéral de string, mais si vous switchez sur Accuracy, une valeur non gérée est au moins visible dans le diff.

Pièges qui mordent les devs Go

Oublier defer resp.Body.Close(). Chaque http.Client.Do qui retourne une réponse non-nil *doit* être suivi d'un Close sur le body, même si vous ne lisez jamais le body. Le runtime Go ne rendra pas la connexion TCP sous-jacente au pool de connexions tant que le body n'est pas fermé et drainé. Sautez-le une fois et votre service fuira des file descriptors sous charge jusqu'à ce que le kernel refuse de nouvelles sockets.

Utiliser le http.Client par défaut. http.Get et compagnie utilisent http.DefaultClient, qui a Timeout: 0. Un serveur mal élevé peut laisser cette goroutine bloquée indéfiniment. Déclarez toujours un client au niveau du package avec un timeout explicite, ou utilisez http.NewRequestWithContext avec un deadline. Idem pour l'histoire de p99 latency — si vous n'avez pas de timeout, vous n'avez pas de p99, vous avez une queue qui court à l'infini.

Deadlocks de channel non bufferisé. Si un worker lit depuis results et que la writer goroutine n'a pas démarré, l'envoi bloque. Si le producer lit depuis jobs et qu'aucun worker n'est vivant, l'envoi bloque. Le buffer concurrency*2 dans l'exemple de streaming est suffisant pour le forward progress ; choisissez un buffer qui laisse le producer rester ~1 pas devant les workers sans stocker l'entrée entière.

Patterns naïfs de WaitGroup + channel. Bug courant : fermer le channel de results avant que les workers terminent, donc la writer goroutine voit un channel fermé et sort avec une sortie partielle. Le fix est l'ordonnancement explicite à la fin de streamGeocode : close(jobs) d'abord (les workers sortent quand ils voient le channel jobs fermé), puis wg.Wait() pour confirmer que tous les workers ont fini, puis close(results), puis <-done pour attendre le writer. Trompez-vous d'ordre et vous verrez des bugs de lignes manquantes qui ne reproduisent que sur de longues entrées.

Foire aux questions

Ai-je besoin d'un SDK ?

Non. La bibliothèque standard — net/http, encoding/json, context, time — couvre tout dans ce post. Le SDK que la plupart des équipes finissent par écrire est un wrapper fin autour de geocodeWithRetry, un set de structs équivalent à Pydantic, et un helper de worker pool. Maintenir ~150 lignes de votre propre code coûte moins cher que de suivre la cadence de release d'un tiers et un breaking change vendoré tous les six mois.

Devrais-je traiter les erreurs comme des valeurs ?

Oui — c'est du Go idiomatique. Le type *GeocodeError dans la fonction de retry porte le statut HTTP comme champ typé pour que les appelants puissent switch dessus sans parser des strings. Encapsulez les erreurs réseau avec fmt.Errorf("...: %w", err) pour que errors.Is et errors.As continuent de fonctionner à travers la call stack. Évitez panic pour tout ce qui traverse une frontière d'API ; réservez les panics aux vrais bugs de programmeur.

net/http versus fasthttp ?

Restez sur net/http pour les clients de geocoding. fasthttp est plus rapide au niveau du microbenchmark mais a une API différente, ne supporte pas HTTP/2, et réutilise les objets request/response entre goroutines de façons qui mordent quand vous les passez à un worker pool. Le bottleneck d'un pipeline de geocoding est le round trip réseau, pas le parser HTTP — fasthttp vous fera économiser des nanosecondes pendant que l'API prend des centaines de millisecondes.

Comment régler concurrency ?

Prenez le rate limit par minute de votre plan, divisez par 60, et visez environ ce nombre de requêtes concurrentes. 1 000/min ÷ 60 ≈ 17, donc une taille de pool de 16 est un défaut sûr sur Starter. La source de vérité est le header X-RateLimit-Remaining — loguez-le sur chaque réponse réussie et surveillez s'il chute plus vite que 1 par requête. La concurrency optimale est une courbe, et le coude se trouve souvent près de (rate_limit / 60) * 1.5. Méthodologie complète dans concurrency tuning pour le geocoding.

Comment détecter un no-match versus un match réussi de basse confiance ?

results est un slice vide en no-match. Sur un match de basse confiance results[0] existe mais accuracy est "postcode" ou "place" plutôt que "houseNumber" ou "street", et accuracy_score est largement en dessous de 1.0. Un seuil par défaut raisonnable est 0.7 ; en dessous, traitez comme no-match. Les pipelines plus stricts (scoring de risque assurance, mapping de patients en santé) utilisent 0.95 et exigent accuracy == "houseNumber". Le tableau complet est dans scores de confiance en geocoding expliqués.

Cela fonctionne-t-il pour des adresses non-US ?

Oui. Passez le bon ISO alpha-2 dans le paramètre countryDE pour l'Allemagne, GB pour le Royaume-Uni, BR pour le Brésil, JP pour le Japon. La couverture s'étend sur 39 pays aujourd'hui, incluant le top 10 complet par nombre d'adresses : USA (121M adresses), Brésil (90M), Mexique (30M), France (26M), Italie (26M), et le reste. La page de l'API liste les comptes par pays.

Devrais-je utiliser des requêtes single ou l'endpoint batch ?

Batch quand vous avez ≥100 adresses à faire d'un coup et que votre plan le permet (Starter+). Un POST coûte moins cher des deux côtés que 100 GETs, et l'économie de latence est environ avg_per_request_ms * count / concurrency. Singles sont plus simples pour des workloads streaming où les adresses arrivent au fil du temps, et singles sont la seule option sur le free tier. L'analyse complète des compromis est dans batch vs realtime geocoding.

Pour aller plus loin

La référence complète de l'API est sur csv2geo.com/api. Si vous préférez travailler dans un autre langage, le tutoriel geocoding Node.js suit la même structure avec fetch et p-limit, et le tutoriel geocoding Python couvre httpx et asyncio. Pour des plongées plus profondes sur les patterns que ce post touche : algorithmes de rate limiting comparés, concurrency tuning pour le geocoding, et pourquoi p99 latency compte plus que la moyenne.

Si vous tombez sur un edge case, sur une forme de réponse que ce post ne couvre pas, ou voulez du feedback sur un pipeline Go que vous construisez, le formulaire de contact du site arrive chez une personne qui le lit. Les bug reports avec curl (ou go run) en reproduction se corrigent vite.

I.A. / CSV2GEO Creator

Articles connexes

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 →