neondatabase/neon · error · DownloadError

Missing ETag header

Error message

Missing ETag header

What it means

After a successful media GET, the backend builds the Download from the earlier-parsed object metadata and requires an ETag; if the etag field was absent from the metadata JSON this error fires. GCS always returns an ETag for objects, so despite the 'header' wording this signals an unexpected response shape (a proxy/gateway omitting the field, or an API change) rather than a normal GCS response missing a header.

Source

Thrown at libs/remote_storage/src/gcs_bucket.rs:843

                crate::metrics::BUCKET_METRICS.req_seconds.observe_elapsed(
                    kind,
                    AttemptOutcome::Err,
                    started_at,
                );

                return Err(DownloadError::Other(
                    anyhow::Error::new(e).context("download s3 object"),
                ));
            }
        };

        let remaining = self.timeout.saturating_sub(started_at.elapsed());

        let metadata = resp.metadata.map(StorageMetadata);

        let etag = resp
            .etag
            .ok_or(DownloadError::Other(anyhow::anyhow!("Missing ETag header")))?
            .into();

        let last_modified: SystemTime = to_system_time(resp.updated).unwrap_or(SystemTime::now());

        // But let data stream pass through
        Ok(Download {
            download_stream: Box::pin(object_output.bytes_stream().map(|item| {
                item.map_err(|e: reqwest::Error| std::io::Error::new(std::io::ErrorKind::Other, e))
            })),
            etag,
            last_modified,
            metadata,
        })
    }
    
    async fn copy_object(
        &self, 
        from: &RemotePath,

View on GitHub (pinned to 8f60b04da4)

Solutions

  1. Capture the raw metadata JSON body and confirm whether etag is genuinely absent versus a deserialization mismatch
  2. Compare the response from real storage.googleapis.com against any intermediary in the path
  3. Retry once — if an anomalous body was transient, the retry succeeds
  4. If a gateway legitimately omits etag, fix its field mapping or make the backend tolerate absence (Option<ETag>)

Example fix

// before: absent etag aborts the whole download
let etag = resp.etag.ok_or(DownloadError::Other(anyhow::anyhow!("Missing ETag header")))?.into();

// after: tolerate absence with a generated fallback identifier
let etag = match resp.etag {
    Some(e) => ETag::from(e),
    None => ETag::from(format!("\"gen-{}\"", generation)),
};
Defensive patterns

Strategy: try-catch

Try / catch

// Unexpected response shape: surface with the key, don't loop retrying.
match storage.download(from, &cancel).await {
    Ok(dl) => Ok(dl),
    Err(DownloadError::Other(e)) if format!("{e:#}").contains("Missing ETag header") => {
        Err(anyhow::anyhow!("metadata for {from} lacked etag; check gateways/proxies in the GCS path: {e:#}"))
    }
    Err(e) => Err(e.into()),
}

Prevention

When it happens

Trigger: download() where the parsed GCSObject metadata has etag == None — a GCS-compatible gateway or emulator dropped the field, an API shape change, or an error body that happened to parse as an object.

Common situations: S3/GCS-compatible proxies or emulators in front of storage that omit etag in JSON responses; rare API anomalies; migrations between endpoint styles.

Related errors


AI-assisted analysis of neondatabase/neon@8f60b04da4 (2026-08-16). Data as JSON: /api/errors/e8e69dfbf8204d5f. Report an issue: GitHub.