8 min de lectureIngénierie

Comment raccourcir une URL en Go avec net/http et errgroup

Raccourcissez une URL en Go avec net/http et un corps JSON, puis ajoutez une échéance de contexte, des nouvelles tentatives avec une Idempotency-Key et un raccourcissement en masse borné avec errgroup.

Marius Voß
DevRel · edge infra
Comment raccourcir une URL en Go : un POST net/http envoie une URL de destination à une API de raccourcissement et décode le lien court, avec une échéance de contexte et une distribution bornée par errgroup

Raccourcir une URL en Go consiste en un seul POST avec un corps JSON. Sérialisez la destination, envoyez-la à l'endpoint de liens du raccourcisseur avec net/http, transmettez votre clé API comme jeton Bearer et décodez short_url dans la réponse. La bibliothèque standard fait tout cela, et la seule dépendance qui vaille la peine d'être ajoutée plus tard est errgroup pour les traitements en masse.

Les résultats de recherche sont dominés par les projets « construisez votre propre raccourcisseur en Go », ce qui est un exercice différent : ceux-ci stockent les liens, tandis que celui-ci appelle un service qui le fait déjà. Si vous cherchez la partie stockage, comment construire un raccourcisseur d'URL couvre les choix de conception. Si vous voulez un lien court dans les cinq prochaines minutes, continuez votre lecture.

Ceci est l'article consacré à Go dans une série qui couvre aussi Python, JavaScript et PHP. La forme de l'endpoint, le modèle d'authentification et les limites du forfait gratuit utilisés ici sont documentés dans l'aperçu de l'API gratuite de raccourcissement d'URL.

La méthode la plus rapide : un POST avec net/http

Deux structs, une requête, un décodage :

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

type linkRequest struct {
	DestinationURL string `json:"destination_url"`
}

type linkResponse struct {
	ID       string `json:"id"`
	ShortURL string `json:"short_url"`
}

func main() {
	body, err := json.Marshal(linkRequest{
		DestinationURL: "https://example.com/spring-sale?utm_source=newsletter",
	})
	if err != nil {
		panic(err)
	}

	req, err := http.NewRequest(http.MethodPost, "https://api.elido.app/v1/links", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		panic("shorten failed: " + resp.Status)
	}

	var link linkResponse
	if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
		panic(err)
	}

	fmt.Println(link.ShortURL) // https://s.elido.me/ab12cd
}

Exécutez go run main.go avec ELIDO_API_KEY exportée et vous obtenez un lien court. Les panics sont acceptables dans un programme de quinze lignes et inadaptées partout ailleurs ; la section suivante transforme cela en fonction qui renvoie des erreurs.

La ligne client := &http.Client{Timeout: ...} est celle à conserver. http.DefaultClient n'a aucun délai d'expiration, donc http.Post vers un hôte qui ne répond pas bloque cette goroutine jusqu'à ce que la connexion meure d'elle-même, ce qui peut prendre plusieurs minutes sur un mauvais réseau.

Donnez une échéance à chaque requête et partagez un client

La forme adaptée à la production : un client au niveau du paquet, un contexte sur chaque requête et des erreurs plutôt que des panics.

var client = &http.Client{
	Timeout: 10 * time.Second,
	Transport: &http.Transport{
		MaxIdleConns:        64,
		MaxIdleConnsPerHost: 16, // default is 2, too low for a concurrent batch
		IdleConnTimeout:     90 * time.Second,
	},
}

const endpoint = "https://api.elido.app/v1/links"

func shorten(ctx context.Context, destination string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	body, err := json.Marshal(linkRequest{DestinationURL: destination})
	if err != nil {
		return "", fmt.Errorf("marshal: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return "", fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("post %s: %w", endpoint, err)
	}
	defer func() {
		io.Copy(io.Discard, resp.Body) // drain so the connection can be reused
		resp.Body.Close()
	}()

	if resp.StatusCode >= 400 {
		return "", fmt.Errorf("shorten: %s", resp.Status)
	}

	var link linkResponse
	if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
		return "", fmt.Errorf("decode: %w", err)
	}
	return link.ShortURL, nil
}

Trois détails justifient leur place. Réutiliser un même http.Client maintient le pool de connexions entre les appels ; construire un client par requête élimine chaque connexion persistante. Vider le corps avant de le fermer est ce qui permet réellement à la connexion de retourner dans le pool, et c'est généralement la raison pour laquelle un traitement par lots ouvre bien plus de sockets qu'il ne devrait. Enfin, context sur la requête signifie qu'un appelant qui abandonne - une CLI annulée ou un utilisateur qui s'est déconnecté - arrête l'appel HTTP au lieu de le laisser s'exécuter.

MaxIdleConnsPerHost mérite son commentaire. La valeur par défaut est 2, ce qui passe inaperçu dans un script et coûte cher dès que huit goroutines interrogent le même hôte.

Un POST Go net/http transportant une échéance de contexte, un jeton Bearer et une Idempotency-Key vers l'endpoint de liens du raccourcisseur, l'API renvoyant HTTP 201 avec short_url, et un chemin de nouvelle tentative qui augmente l'attente pour 429 et 5xx tout en respectant l'annulation du contexte

Réessayez sur 429 et 5xx sans créer de doublons

Il existe un mode d'échec qu'une simple boucle de nouvelle tentative aggrave. Le POST atteint l'API, le lien est créé, puis la réponse se perd sur le chemin du retour. Votre code constate un délai dépassé, réessaie, et deux liens courts pointent maintenant vers une même destination. Une Idempotency-Key ferme cette brèche : même clé, même requête logique, et l'API renvoie le lien d'origine au lieu d'en créer un autre.

Calculez la clé à partir de la destination et elle restera stable entre les nouvelles tentatives et les réexécutions du même traitement par lots.

var (
	errRateLimited = errors.New("rate limited")
	errServer      = errors.New("server error")
)

func shortenWithRetry(ctx context.Context, destination string, attempts int) (string, error) {
	key := fmt.Sprintf("%x", sha256.Sum256([]byte(destination)))

	var lastErr error
	for attempt := 0; attempt < attempts; attempt++ {
		short, retryAfter, err := postLink(ctx, destination, key)
		if err == nil {
			return short, nil
		}

		var wait time.Duration
		switch {
		case errors.Is(err, errRateLimited):
			wait = retryAfter // from the Retry-After header
		case errors.Is(err, errServer):
			wait = time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
		default:
			return "", err // 401, 403, 422: retrying will not help
		}

		lastErr = err
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-time.After(wait):
		}
	}
	return "", fmt.Errorf("shorten %q: %w", destination, lastErr)
}

Remarquez le select à la place de time.Sleep. La veille ignore l'annulation, donc un traitement à qui l'on demande de s'arrêter attend encore quatre secondes par URL. Attendre simultanément ctx.Done() et time.After rend le délai d'attente interruptible, ce qui fait la différence entre un arrêt propre et un SIGKILL. L'analyse approfondie des limites de débit et de l'idempotence couvre la sémantique de l'en-tête et explique pourquoi les nouvelles tentatives aveugles transforment une panne en deux.

Vous voulez exécuter cela sur un endpoint actif ? Créez une clé avec le forfait gratuit, exportez ELIDO_API_KEY, et chaque extrait de code de cet article se compile et s'exécute tel quel.

Raccourcissez en masse avec errgroup et SetLimit

La version concurrente naïve lance une goroutine par URL. Avec 5 000 URL, cela fait 5 000 requêtes simultanées, et l'API répond à la plupart par 429. errgroup et SetLimit vous donnent une distribution avec un plafond :

func shortenAll(ctx context.Context, urls []string) (map[string]string, error) {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(8) // at most 8 requests in flight, whatever len(urls) is

	var mu sync.Mutex
	out := make(map[string]string, len(urls))

	for _, u := range urls {
		g.Go(func() error {
			short, err := shortenWithRetry(ctx, u, 3)
			if err != nil {
				return fmt.Errorf("%s: %w", u, err)
			}
			mu.Lock()
			defer mu.Unlock()
			out[u] = short
			return nil
		})
	}

	return out, g.Wait()
}

Avec Go 1.22 et les versions ultérieures, la variable de boucle est propre à chaque itération, donc l'ancienne ligne u := u à l'intérieur de la boucle disparaît. Le mutex reste nécessaire : une map écrite par plusieurs goroutines sans mutex est une course aux données, et go test -race vous le signalera.

Une goroutine par URL inondant l'API de raccourcissement avec des requêtes limitées par le débit, comparée à un errgroup utilisant SetLimit pour maintenir un nombre borné de requêtes de raccourcissement en masse en cours

Un comportement à connaître avant le déploiement : errgroup.WithContext annule le contexte partagé dès qu'une goroutine renvoie une erreur, donc le premier échec sérieux arrête le reste du traitement par lots. C'est ce qu'il vous faut pour une étape de build qui doit être entièrement réussie ou entièrement échouer. Pour un importateur qui doit raccourcir tout ce qu'il peut et signaler les échecs à la fin, rassemblez les erreurs dans une slice, renvoyez nil depuis chaque goroutine et utilisez le groupe uniquement comme limiteur de concurrence.

Client typé ou net/http brut

Pour un seul endpoint, le code ci-dessus constitue toute l'intégration et une dépendance n'apporte rien. Le calcul change lorsque vous commencez à lister les liens avec pagination, à filtrer par tag, à lire les totaux de clics et à gérer une demi-douzaine de formats de réponse : c'est à ce moment que les modèles générés et un client qui connaît déjà les règles de nouvelle tentative rapportent plus qu'ils ne coûtent. La page API et SDK contient la liste actuelle, et le démarrage rapide du SDK montre la version typée de ce même appel.

Dans tous les cas, les habitudes sont identiques : la clé vient de l'environnement, une échéance sur chaque requête, le code d'état vérifié avant le corps, une clé d'idempotence stable pour tout ce qui peut être exécuté deux fois et un plafond de concurrence. Dès que les liens sont créés depuis un service plutôt que depuis un ordinateur portable, les webhooks pour les événements de liens sont préférables à l'interrogation pour savoir ce qui leur est arrivé, et ce que la plateforme expose aux développeurs couvre le reste de la surface.

Lisez la série de référence

Cet article appartient au cluster engineering. Commencez par le guide de l'API gratuite de raccourcissement d'URL pour la forme de l'endpoint et l'authentification, puis consultez l'article sur les limites de débit et l'idempotence pour le comportement sous charge. La référence à jour est la documentation de l'API, et les raccourcisseurs d'URL pour les développeurs explique ce qu'il faut vérifier dans une API avant de construire dessus.

Articles associés sur le blog

Questions fréquentes

Comment raccourcir une URL en Go ?

Sérialisez l'URL longue dans un corps JSON, envoyez-la en POST à l'endpoint de liens du raccourcisseur avec net/http, définissez votre clé API dans un en-tête Bearer, puis décodez short_url dans la réponse. La bibliothèque standard couvre tout cela, donc la fonction complète tient en une vingtaine de lignes sans dépendance tierce.

Ai-je besoin d'une bibliothèque pour raccourcir des URL en Go ?

Non. net/http et encoding/json suffisent pour un POST unique, et le seul paquet externe qui vaille la peine d'être ajouté est golang.org/x/sync/errgroup pour borner la concurrence dans les traitements en masse. Un SDK généré devient intéressant lorsque vous utilisez de nombreux endpoints et voulez des modèles typés et une pagination plutôt qu'un seul appel.

Comment définir un délai d'expiration sur une requête HTTP Go ?

Définissez Timeout sur votre propre http.Client et construisez les requêtes avec http.NewRequestWithContext et context.WithTimeout. http.DefaultClient n'a aucun délai d'expiration, donc un serveur bloqué peut retenir une goroutine indéfiniment. Le délai du client couvre tout l'échange ; le contexte permet aussi à l'appelant d'annuler plus tôt.

Comment raccourcir de nombreuses URL simultanément en Go ?

Utilisez errgroup avec SetLimit pour qu'un nombre fixe de requêtes seulement soit en cours, au lieu de lancer une goroutine par URL et de subir une limitation de débit. Protégez la map des résultats avec un mutex et envoyez une Idempotency-Key stable par URL afin que les nouvelles tentatives ne créent jamais de liens en double.

Pourquoi mon client HTTP Go ne réutilise-t-il pas les connexions ?

Généralement parce que le corps de la réponse n'est jamais entièrement lu avant sa fermeture, et que la connexion ne peut donc pas retourner dans le pool inactif. Videz-le avec io.Copy(io.Discard, resp.Body) avant Close. L'autre cause courante est la valeur par défaut du transport, soit deux connexions inactives par hôte, ce qui est peu pour un traitement par lots concurrent.

Pourquoi ma requête Go vers un raccourcisseur d'URL renvoie-t-elle 401 ?

L'en-tête Authorization est absent, mal orthographié ou contient une variable d'environnement vide. Affichez os.Getenv avant la requête pour confirmer que la clé est bien chargée et vérifiez que l'en-tête contient 'Bearer ' avec l'espace final avant la clé. Un 403 signifie au contraire que la clé est valide mais n'a pas la portée nécessaire pour cet endpoint.

Essayer Elido

Collez une URL, obtenez un lien court

Sans inscription. Lien actif 30 jours. Inscrivez-vous pour le garder pour toujours.

Gratuit, sans inscription · 2 par jour

Essayer Elido

Raccourcisseur d'URL hébergé en UE : domaines personnalisés, analyses approfondies et API ouverte. Forfait gratuit - sans carte bancaire.

Tags
how to shorten a url in go
golang url shortener api
shorten url go net/http
go http client post json
bulk shorten urls golang
errgroup setlimit

Lire la suite