proxy-range-header-forwarding

Par divinevideo · divine-mobile

Corrige l'erreur iOS AVPlayer "CoreMediaErrorDomain error -12939 - byte range length mismatch" ou les échecs similaires de streaming vidéo lorsqu'un service proxy/edge/CDN se trouve entre le client et un stockage objet (GCS, S3, R2). À utiliser quand : (1) la vidéo iOS échoue avec -12939 "byte range length mismatch - should be length 2 is length N", (2) curl avec Range: bytes=0-1 retourne le fichier complet au lieu de 2 octets, (3) un chemin d'URL fonctionne avec Range mais un autre chemin vers le même stockage ne fonctionne pas, (4) le proxy annonce Accept-Ranges: bytes mais retourne un 200 avec le fichier complet pour les requêtes de plage. La cause racine est généralement un handler proxy qui construit une nouvelle requête vers le backend sans transmettre le header Range du client.

npx skills add https://github.com/divinevideo/divine-mobile --skill proxy-range-header-forwarding

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 retourne 200 avec Content-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 206 avec Content-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 :

  1. Fonction de stockage : ajouté le paramètre range: Option<&str>
  2. Fonction wrapper : range forwarded
  3. 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: bytes aux 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-1 avant 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.

Références

Skills similaires