Un commerçant envoie un export de catalogue de 2,3 Go vers votre API. La requête traîne, puis meurt sans message d’erreur exploitable. Le service encaisse pourtant des centaines de petits uploads par minute sans souci. Le bug ne se voit jamais en local, parce que les fichiers de test tiennent en quelques kilo-octets.
C’est l’anecdote que raconte Erwin Hermanto dans son article 👇
Un
ioutil.ReadAll()sur le corps de la requête et c’est tout le fichier qui est chargé en mémoire.
💥 Pourquoi ReadAll charge tout
Le code : NewReadAllHandler
// internal/upload/handler.go (extrait de NewReadAllHandler)
_, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
http.Error(w, "invalid content type", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusInternalServerError)
return
}
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
part, err := reader.NextPart()
if err != nil {
http.Error(w, "no file part", http.StatusBadRequest)
return
}
defer part.Close()
if err = storeUnderClientName(dest, part.FileName(), part); err != nil {
http.Error(w, "upload failed", http.StatusInternalServerError)
}
io.ReadAll(r.Body) lit le corps morceau par morceau, en agrandissant la taille des morceaux tant qu’il reste des données, puis recopie le tout dans un slice final (Go 1.27, mesuré : 2,6 fois la taille du fichier sur 1 Gio).
Un service qui tourne bien avec des petits fichiers s’effondre à la première pièce jointe volumineuse, sans aucune erreur avant l’OOM kill.
📝 Le test unitaire passe ✅ car le fichier de test fait 12 Ko.
🩹 FormFile règle la moitié du problème
Le code : NewFormFileHandler
// internal/upload/handler.go (extrait de NewFormFileHandler)
if err := r.ParseMultipartForm(maxMemory); err != nil {
http.Error(w, fmt.Sprintf("invalid multipart form: %v", err), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file field", http.StatusBadRequest)
return
}
defer file.Close()
if err = storeUnderClientName(dest, header.Filename, file); err != nil {
http.Error(w, "upload failed", http.StatusInternalServerError)
}
r.ParseMultipartForm(maxMemory) et r.FormFile(name), qui l’appelle avec 32 Mo par défaut, déplacent le problème plutôt que de le résoudre.
Avant de rendre la main au handler, ParseMultipartForm lit le corps de la requête jusqu’au bout : il garde en mémoire les premiers maxMemory octets et écrit le reste dans des fichiers temporaires, créés par os.CreateTemp avec le préfixe multipart-.
Le plafond maxMemory + 10 Mo ne borne que les données du formulaire gardées en mémoire, pas le fichier une fois basculé sur disque : dans notre mesure, le heap reste sous les 100 Mio.
Résultat : plus de heap qui explose, mais un disque qui se remplit et un handler qui attend la fin du transfert avant de commencer. Pour un gros fichier, la requête bloque une goroutine pendant toute la durée de l’upload, sans rien produire entre-temps.
🔀 Lire part par part avec MultipartReader
r.MultipartReader() donne accès aux parts une par une, au fur et à mesure qu’elles arrivent sur le réseau, mais ne se mélange pas avec ParseMultipartForm ou FormFile : l’un après l’autre renvoie une erreur explicite (« multipart handled by … »).
Le code : NewMultipartReaderHandler
// internal/upload/handler.go (extrait de NewMultipartReaderHandler)
reader, err := r.MultipartReader()
if err != nil {
http.Error(w, "invalid multipart body", http.StatusBadRequest)
return
}
part, err := reader.NextPart()
if err != nil {
http.Error(w, "no file part", http.StatusBadRequest)
return
}
defer part.Close()
if err = storeUnderClientName(dest, part.FileName(), part); err != nil {
writeError(w, err)
}
writeError traduit l’erreur en réponse HTTP : on y revient avec le 413. storeUnderClientName crée le fichier dans dest sous le nom envoyé par le client (réduit à son filepath.Base), puis y copie la part avec io.Copy : pratique pour l’exemple, mais à ne pas faire en production, deux homonymes s’écrasant l’un l’autre. On corrige ça dans l’article 2.
Le code : storeUnderClientName
// internal/upload/handler.go
func storeUnderClientName(dest, clientName string, src io.Reader) error {
path := filepath.Join(dest, filepath.Base(clientName))
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
if _, err = io.Copy(dst, src); err != nil {
_ = dst.Close()
return fmt.Errorf("copy upload: %w", err)
}
if err = dst.Close(); err != nil {
return fmt.Errorf("close %s: %w", path, err)
}
return nil
}
À ce stade, la mémoire consommée par requête ne dépend plus de la taille du fichier, mais de celle du buffer d’io.Copy. C’est le socle sur lequel l’article 2 va empiler détection de type et hash, sans rien changer à cette structure.
🚧 Borner la taille et écrire le 413 vous-même
Rien n’empêche encore un client d’envoyer 500 Go. http.MaxBytesReader enveloppe r.Body et coupe la lecture au-delà d’une limite donnée.
Il ne renvoie pas de status 413 lui-même : il produit une *http.MaxBytesError (Go ≥ 1.19) et marque la connexion pour fermeture, à charge pour le handler de traduire ça en réponse HTTP.
Posez la limite en première ligne du handler, avant même l’appel à MultipartReader() : ce dernier capture r.Body tel qu’il le trouve.
Le code : MaxBytesReader et le 413
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
// …
if err = storeUnderClientName(dest, part.FileName(), part); err != nil {
writeError(w, err)
}
// internal/upload/receive.go (extrait de writeError)
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "upload failed", http.StatusInternalServerError)
L’erreur remonte bien à travers Part.Read, io.Copy et l’enveloppe %w de storeUnderClientName : errors.As, dans writeError, la reconnaît sans traitement particulier.
📊 Ce que ça donne en mémoire
Les trois approches (ReadAll, FormFile/ParseMultipartForm, MultipartReader en flux) sont mesurées dans le dépôt d’exemple de la série, sur un upload de 1 Gio (Go 1.27.1, Apple M5 Max).
| Approche | Pic mémoire (heap) | Fichiers temporaires | Débit |
|---|---|---|---|
io.ReadAll |
2625,1 Mio | Non | 681,4 Mio/s |
ParseMultipartForm / FormFile |
97,8 Mio | Oui (1) | 753,1 Mio/s |
MultipartReader en flux |
1,8 Mio | Non | 926,2 Mio/s |
ReadAllmonte à environ 2,6 fois la taille du fichier,FormFileplafonne sous les 100 Mio grâce à la bascule sur disque,- Et le flux reste sous 2 Mio.
Le débit ne départage pas vraiment les approches : il varie d’une mesure à l’autre.
Les benchmarks Go du dépôt (go test -bench) confirment la tendance sur un fichier de 256 Mio. Ils mesurent cette fois les octets alloués au total par requête, pas le pic de heap :
| Approche | Alloué par requête (256 Mio) |
|---|---|
io.ReadAll |
572 Mio |
ParseMultipartForm / FormFile |
128 Mio |
MultipartReader en flux |
88 Kio |
Dépôt et code de benchmark : clevertechware/upload-fichier-go.
⚠️ Pièges
ReadAllsur un upload : pic mémoire proportionnel au fichier, pas à vos tests.MultipartReader()+ParseMultipartForm/FormFilesur la même requête : erreur immédiate.MaxBytesReaderposé aprèsMultipartReader(): trop tard,r.Bodyest déjà capturé.- Pas de
errors.Assur*http.MaxBytesError: le 413 devient une 500 générique.
