neondatabase/neon · error · DownloadError
Azure GET response contained no response body
Error message
Azure GET response contained no response body
What it means
In download_for_builder, the Azure blob GET is turned into a stream; the first next() must yield at least one response part carrying the blob's etag/last_modified/metadata. If the stream ends before yielding anything (zero parts), the code cannot even construct the Download and returns DownloadError::Other with this message. An actual network/HTTP failure surfaces as a different DownloadError variant, so this specifically means: request nominally succeeded but the body stream was empty.
Source
Thrown at libs/remote_storage/src/azure_blob.rs:207
.into_stream()
// convert to TryStream
.into_stream()
.map_err(to_download_error);
// apply per request timeout
let response = tokio_stream::StreamExt::timeout(response, timeout);
// flatten
let response = response.map(|res| match res {
Ok(res) => res,
Err(_elapsed) => Err(DownloadError::Timeout),
});
let mut response = Box::pin(response);
let Some(part) = response.next().await else {
return Err(DownloadError::Other(anyhow::anyhow!(
"Azure GET response contained no response body"
)));
};
let part = part?;
if etag.is_none() {
etag = Some(part.blob.properties.etag);
}
if last_modified.is_none() {
last_modified = Some(part.blob.properties.last_modified.into());
}
if let Some(blob_meta) = part.blob.metadata {
metadata.extend(blob_meta.iter().map(|(k, v)| (k.to_owned(), v.to_owned())));
}
// unwrap safety: if these were None, bufs would be empty and we would have returned an error already
let etag = etag.unwrap();
let last_modified = last_modified.unwrap();
let tail_stream = responseView on GitHub (pinned to 8f60b04da4)
Solutions
- Retry the download: this error is almost always transient (wrap with backoff, e.g. 3 attempts)
- Check Azure status / storage account metrics if it repeats for the same key
- Verify the blob exists and is non-empty via az CLI or another tool to rule out a real data issue
- Upgrade the azure_storage_blobs SDK if an older version mishandles connection reuse
Example fix
// before
let download = remote_storage.download(&path).await?;
// after: retry the transient empty-body case with backoff
let download = backoff::future::retry_notify(
backoff::ExponentialBackoff::default(),
|| async {
match remote_storage.download(&path).await {
Err(DownloadError::Other(e))
if e.to_string().contains("no response body") =>
{
Err(backoff::Error::transient(e))
}
other => other.map_err(backoff::Error::permanent),
}
},
|e, d| tracing::warn!("azure empty body retry after {d:?}: {e:#}"),
)
.await?; Defensive patterns
Strategy: retry
Type guard
fn is_empty_body_download_error(e: &DownloadError) -> bool {
matches!(e, DownloadError::Other(inner)
if inner.to_string().contains("no response body"))
} Try / catch
use backoff::ExponentialBackoff;
// 'no response body' is transient Azure weirdness: retry, don't propagate.
let download = backoff::future::retry(
ExponentialBackoff::default(),
|| async {
match storage.download(&path, &cancel).await {
Err(DownloadError::Other(e))
if e.to_string().contains("no response body") =>
{
tracing::warn!(?path, "azure GET returned empty body; retrying");
Err(backoff::Error::transient(e))
}
Err(DownloadError::Timeout) => {
// timeouts are also transient for this endpoint
Err(backoff::Error::transient(anyhow::anyhow!("download timeout")))
}
other => other.map_err(backoff::Error::permanent),
}
},
)
.await?; Prevention
- Wrap all remote-storage downloads in retry-with-backoff; classify empty-body and Timeout as transient, 404 as permanent
- Track retry rate per storage backend -- a rise in empty-body errors predicts egress or Azure incidents
- Keep the azure SDK and connection pool settings current; stale pooled connections are a known source of truncated streams
- Never treat one empty-body failure as data corruption; verify blob existence with a second path before escalating
When it happens
Trigger: Azure returning an empty response stream for a GET blob call -- transient service weirdness, a race where the timeout wrapper fires before the first part is delivered but is reported as stream end, or SDK-level desync after connection reuse. Rare, and typically intermittent rather than deterministic.
Common situations: Flaky egress paths or middleboxes truncating chunked responses; retries hitting an Azure node mid-failover; a zero-byte blob accessed through a code path that still expects header metadata parts; stress tests saturating the connection pool so streams complete prematurely.
Related errors
- max_keys_per_list_response can't be 0
- not implemented
- Remote extensions storage is not configured
- error downloading extension {:?}: {:?}
- Failed to upload all blocks {:#?}
AI-assisted analysis of neondatabase/neon@8f60b04da4 (2026-08-16).
Data as JSON: /api/errors/c360c4126491752d.
Report an issue: GitHub.