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).

io.ReadAll lit le corps en morceaux de taille croissante, puis les recopie dans un slice final : les deux coexistent en mémoire 1. Lecture morceaux ×1,5 1 2 3 4 N octets 2. Copie finale tout est recopié 1 2 3 4 slice final : N octets pic ≥ 2N 2,6N mesuré
Au moment de la copie finale, les morceaux lus et le slice final coexistent : le pic dépasse le double du fichier.

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
  • ReadAll monte à environ 2,6 fois la taille du fichier,
  • FormFile plafonne 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

  • ReadAll sur un upload : pic mémoire proportionnel au fichier, pas à vos tests.
  • MultipartReader() + ParseMultipartForm/FormFile sur la même requête : erreur immédiate.
  • MaxBytesReader posé après MultipartReader() : trop tard, r.Body est déjà capturé.
  • Pas de errors.As sur *http.MaxBytesError : le 413 devient une 500 générique.

🔗 Liens