Chez un client récent, j’ai dû mettre à jour un lot d’entités via une API downstream :
un
PATCHpar entité, plusieurs dizaines à chaque exécution.
En séquentiel, c’était fiable, mais lent. En parallèle sans limites, ça faisait tomber le service downstream sous la charge.
La solution : paralléliser, mais borner. Voici comment, avec un client Resty et errgroup.
🎯 Le vrai problème : vite, mais pas trop vite
Deux contraintes qui tirent en sens inverse :
- La latence globale — un
PATCHprend 150-300ms.
Sur 50 entités en séquentiel, ça fait facilement 10-15 secondes. Trop lent pour un batch déclenché depuis une interface utilisateur. - La capacité du downstream — le service en face n’est pas taillé pour encaisser 50 requêtes simultanées.
Au-delà d’un certain seuil :429 Too Many Requests, timeouts en cascade, parfois un service qui redémarre.
🐢 Les deux extrêmes à éviter
Séquentiel — simple, mais la latence totale grandit linéairement avec le nombre d’entités :
for _, e := range entities {
if _, err := client.R().SetBody(e).Patch(url(e.ID)); err != nil {
// ...
}
}
Tout en parallèle, sans limite — rapide, mais dangereux dès que le lot grossit :
// ❌ 50 entités = 50 requêtes HTTP simultanées, aucun frein
for _, e := range entities {
go func(e Entity) {
client.R().SetBody(e).Patch(url(e.ID))
}(e)
}
Aucune des deux versions ne convient pour un batch de taille variable envoyé à un service qu’on ne contrôle pas.
🛠️ Le wrapper Resty
Premier point important : le *resty.Client doit être créé une seule fois et partagé entre toutes les goroutines.
Il est thread-safe et embarque son propre pool de connexions HTTP — en créer un par requête, c’est perdre le bénéfice du keep-alive et rouvrir une connexion TCP à chaque appel.
package entityclient
import (
"context"
"fmt"
"github.com/go-resty/resty/v2"
)
type EntityClient struct {
http *resty.Client
}
func New(baseURL string) *EntityClient {
return &EntityClient{
http: resty.New().SetBaseURL(baseURL),
}
}
func (c *EntityClient) update(ctx context.Context, e Entity) error {
res, err := c.http.R().
SetContext(ctx).
SetBody(e).
Patch(fmt.Sprintf("/entities/%s", e.ID))
if err != nil {
return fmt.Errorf("update entity %s: %w", e.ID, err)
}
if res.IsError() {
return fmt.Errorf("update entity %s: status %d", e.ID, res.StatusCode())
}
return nil
}
SetContext(ctx) est ce qui permet à la requête de respecter une annulation ou un timeout : Resty construit la requête HTTP sous-jacente avec http.NewRequestWithContext, donc tout ce qui annule le ctx annule la requête en cours.
🚦 Borner avec errgroup
C’est ici que se joue le contrôle du parallélisme. errgroup.Group.SetLimit(n) fait exactement ce qu’on veut.
Au plus
ngoroutines, lancées viag.Go, tournent en même temps et les suivantes attendent qu’une place se libère.
package entityclient
import (
"context"
"errors"
"golang.org/x/sync/errgroup"
)
const maxParallelUpdates = 5
func (c *EntityClient) UpdateAll(ctx context.Context, entities []Entity) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(maxParallelUpdates)
errs := make([]error, len(entities))
for i, e := range entities {
i, e := i, e // capture explicite (précaution avant Go 1.22)
g.Go(func() error {
errs[i] = c.update(ctx, e)
return nil // volontaire : on ne fait pas échouer le groupe, voir plus bas
})
}
_ = g.Wait()
return errors.Join(errs...)
}
Ce qui se passe :
SetLimit(5)transformeg.Goen point de blocage : la 6ᵉ goroutine attend qu’une des 5 premières se termine avant de démarrer.
C’est la back-pressure, gratuite, sans channel à gérer soi-même.- Chaque goroutine écrit dans son propre index de
errs.
Aucune donnée partagée entre goroutines, donc pas besoin de mutex, pas de race condition. errors.Join(errs...)agrège toutes les erreurs non nulles en une seule.
Lesnilsont ignorés, et si tout s’est bien passé,errors.Joinrenvoienil.
⚠️ Les pièges à éviter
1. Créer un nouveau client par requête
// ❌ Un pool de connexions différent à chaque appel
func (c *EntityClient) update(ctx context.Context, e Entity) error {
client := resty.New() // perd le keep-alive, rouvre une connexion TCP
// ...
}
Solution : un seul *resty.Client réutilisé par toutes les goroutines.
2. Confondre fail-fast et collecte d’erreurs
Le comportement natif d’errgroup est le fail-fast : dès qu’une fonction, passée à g.Go, retourne une erreur non nulle, le ctx issu de errgroup.WithContext est annulé, ce qui coupe court aux appels encore en vol.
C’est le bon choix quand les tâches sont liées entre elles (un échec rend les autres inutiles).
Ici, les updates portent sur des entités indépendantes.
👉 L’échec de l’une ne doit pas empêcher les autres d’aboutir. Le return nil, dans g.Go, stocke l’erreur sans la faire remonter au groupe.
// ✅ Fail-fast : à utiliser si les tâches dépendent les unes des autres
g.Go(func() error {
return c.update(ctx, e) // une erreur ici annule ctx pour tous
})
// ✅ Collecte : à utiliser pour des tâches indépendantes (notre cas)
g.Go(func() error {
errs[i] = c.update(ctx, e)
return nil
})
3. Oublier SetContext
Sans SetContext(ctx) sur chaque requête, un timeout ou une annulation du ctx parent n’a aucun effet sur les appels HTTP en cours.
Ils continuent jusqu’à leur propre timeout réseau, voire indéfiniment.
4. Capturer la variable de boucle (avant Go 1.22)
Même piège que pour un Worker Pool classique : sans i, e := i, e, toutes les goroutines lancées avant Go 1.22 partagent la même variable de boucle et peuvent traiter la mauvaise entité, ou écrire dans le mauvais index de errs.
5. Choisir une limite au hasard
Dans mon cas, 5 n’était pas une valeur magique.
C’était un compromis à ajuster selon la capacité réelle du service downstream (son rate limit, son nombre de workers, sa base de données). Commencer bas, monter progressivement en observant les taux d’erreur 429/5xx est plus sûr que de deviner.
📊 En pratique
| Stratégie | Latence (50 entités) | Charge sur le downstream | Risque |
|---|---|---|---|
| Séquentiel | ~10-15s | Minimale | Aucun, mais lent |
| Parallèle sans limite | ~1-2s | Pic à 50 requêtes simultanées | 429, timeouts, instabilité |
| Parallèle borné à 5 | ~3-4s | Pic à 5 requêtes simultanées | Contrôlé |
Le borné à 5 n’est pas le plus rapide sur le papier, en revanche, il permet de garder une certaine fiabilité en production.
📝 En résumé
- Client partagé — un seul
*resty.Client, créé une fois, réutilisé par toutes les goroutines errgroup.SetLimit(n)— borne le nombre de requêtes en vol sans channel à gérer soi-même- Fail-fast vs collecte —
return nildansg.Gopour des tâches indépendantes,return errsi elles sont liées errors.Join— agrège proprement les erreurs partielles,nil-safeSetContext(ctx)— indispensable pour que l’annulation et les timeouts fonctionnent réellement- La limite se mesure — pas de valeur magique, elle se cale sur la capacité du service en face
👉 Paralléliser des appels HTTP en Go n’est pas compliqué, mais les garder sous contrôle, un peu plus.

