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
- Capture the raw metadata JSON body and confirm whether etag is genuinely absent versus a deserialization mismatch
- Compare the response from real storage.googleapis.com against any intermediary in the path
- Retry once — if an anomalous body was transient, the retry succeeds
- 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
- Do not put field-dropping proxies between the client and GCS
- Monitor for this error after any endpoint/gateway change — it detects silent response-shape drift
- Keep an integration test that downloads a real object end-to-end through your production path
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
- Missing size (content length) header
- no 'updated' field
- no items returned
- max keys reached
- GCS PUT error \n\t {:?}
AI-assisted analysis of neondatabase/neon@8f60b04da4 (2026-08-16).
Data as JSON: /api/errors/e8e69dfbf8204d5f.
Report an issue: GitHub.