Forwarding d'en-têtes Range de proxy pour le streaming vidéo
Problème
Un service proxy/edge (Fastly Compute, Cloudflare Workers, reverse proxy personnalisé) s'intercale
entre les clients et le stockage objet. Certaines routes forwarden correctement les en-têtes HTTP Range
vers le backend de stockage, mais d'autres routes (ajoutées ultérieurement ou par d'autres développeurs)
construisent les requêtes backend from scratch sans forwarder les en-têtes Range. Cela provoque l'échec
d'iOS AVPlayer car il teste avec Range: bytes=0-1 et rejette les réponses où la taille du corps ne correspond pas à la plage demandée.
Contexte / Conditions de déclenchement
- Erreur iOS :
CoreMediaErrorDomain error -12939 - byte range length mismatch - should be length 2 is length N PlatformException(VideoError, Failed to load video: Operation Stopped)curl -H "Range: bytes=0-1"sur l'URL défaillante retourne200avecContent-Lengthégale à la taille du fichier complet- Le même test curl sur un chemin d'URL différent vers le même stockage retourne
206avecContent-Length: 2 - Le code proxy/edge possède plusieurs handlers de routes qui construisent indépendamment les requêtes backend
- Les en-têtes de réponse incluent
Accept-Ranges: bytes(annonçant trompeusement le support)
Motif de cause racine
Dans les architectures proxy, chaque handler de route construit indépendamment les requêtes vers le backend de stockage. Il est courant que le handler « original » forwarde correctement les en-têtes Range tandis que les handlers variants (variantes de qualité, miniatures, versions transcodées) construisent les requêtes from scratch sans considérer Range.
// BROKEN: Handler ignores client Range header
fn handle_variant(req: Request, path: &str) -> Response {
let gcs_path = resolve_variant(path);
let backend_req = Request::new(Method::GET, &gcs_url); // No Range header!
backend_req.send("storage")
}
// WORKING: Handler forwards Range header
fn handle_original(req: Request, path: &str) -> Response {
let range = req.get_header("Range"); // Extracts Range
let backend_req = Request::new(Method::GET, &gcs_url);
if let Some(r) = range {
backend_req.set_header("Range", r); // Forwards it
}
backend_req.send("storage")
}
Solution
Trois choses doivent toutes être corrigées dans le handler proxy :
1. Extraire l'en-tête Range de la requête client
let range = req
.get_header(header::RANGE)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
2. Forwarder l'en-tête Range vers le backend de stockage
if let Some(range_value) = range {
backend_req.set_header("Range", range_value);
}
3. Accepter les réponses 206 du backend
// BEFORE (broken): only accepts 200
match resp.get_status() {
StatusCode::OK => Ok(resp),
...
}
// AFTER (fixed): accepts both 200 and 206
match resp.get_status() {
StatusCode::OK | StatusCode::PARTIAL_CONTENT => Ok(resp),
...
}
GCS/S3/R2 traitent nativement les en-têtes Range et retournent un 206 Partial Content approprié avec
les en-têtes Content-Range et Content-Length corrects, donc une fois forwardés, la réponse
peut être passée directement au client.
Vérification
# Test l'endpoint défaillant avec une sonde de plage (ce que fait iOS)
curl -sv -H "Range: bytes=0-1" "https://cdn.example.com/hash/variant" \
-o /dev/null 2>&1 | grep -iE "< HTTP|content-length|content-range"
# Expected AFTER fix:
# < HTTP/2 206
# < content-range: bytes 0-1/TOTAL_SIZE
# < content-length: 2
# Test mid-file range
curl -sv -H "Range: bytes=1000-1999" "https://cdn.example.com/hash/variant" \
-o /dev/null 2>&1 | grep -iE "< HTTP|content-length|content-range"
# Expected: 206, content-length: 1000, content-range: bytes 1000-1999/TOTAL
# Test full download still works (no Range header)
curl -sv "https://cdn.example.com/hash/variant" \
-o /dev/null 2>&1 | grep -iE "< HTTP|content-length"
# Expected: 200, content-length: TOTAL_SIZE
Exemple
Cas réel : Service edge Fastly Compute proxying vers GCS. La route /{hash} (blob original)
forwardait les en-têtes Range via download_blob(hash, range). La route /{hash}/720p
(variante de qualité transcodée) appelait download_hls_from_gcs(gcs_key) sans paramètre range.
La correction a nécessité des changements dans trois couches :
- Fonction de stockage : ajouté le paramètre
range: Option<&str> - Fonction wrapper : range forwarded
- Handler de route : Range extrait de la requête client
Notes
- Auditer tous les handlers de route : Si un handler manque le forwarding Range, d'autres le font probablement aussi. Rechercher tous les endroits qui construisent des requêtes backend et vérifier que Range est forwardé.
- Ne pas juste set Accept-Ranges : Ajouter
Accept-Ranges: bytesaux réponses sans réellement gérer les ranges est pire que de ne pas l'annoncer - les clients attendront que ça fonctionne. - iOS est strict : Safari/AVPlayer teste toujours avec
Range: bytes=0-1avant le streaming. Chrome/Android sont plus tolérants et peuvent fonctionner sans support Range correct. - Requêtes HEAD : Les handlers HEAD n'ont pas besoin de forwarding Range (ils ne retournent que des métadonnées), mais les handlers GET absolument.
- Couches de cache : Si un proxy de cache se trouve devant, il peut mettre en cache la réponse 200 complète et la servir pour les requêtes Range. Purger le cache après deployer la correction.