use crate::sql::SqlTarget;
pub struct Diagnosis {
pub summary: String,
pub hint: String,
}
pub fn diagnose(target: &SqlTarget, context: Option<&str>, raw: &str) -> Option<Diagnosis> {
let lower = raw.to_lowercase();
let is_s3 = matches!(target, SqlTarget::S3(_));
let path_desc = context
.map(|c| format!("'{c}'"))
.unwrap_or_else(|| "the path".to_string());
if lower.contains("out of memory") || lower.contains("failed to allocate data") {
return Some(Diagnosis {
summary: "DuckDB ran out of memory.".into(),
hint: format!(
"The operation on {path_desc} exceeded the server's DuckDB memory budget \
(`[sql].memory_limit`, default 512MB). Try raising `[sql].memory_limit` \
(e.g. '2GB') if the host has the RAM. For interactive queries, lowering \
`[sql].threads` reduces peak scan memory by limiting concurrent DuckDB \
workers. For large-file conversions, lowering `[sql].convert_threads` \
also reduces peak memory by limiting the number of non-spillable Parquet \
write buffers held concurrently."
),
});
}
if lower.contains("localfilesystem") && lower.contains("disabled") {
let hint = if is_s3 {
format!(
"The operation on {path_desc} required access to the server's local filesystem, which is \
disabled in S3 mode. The path is already an s3:// URI, so this is not a wrong-path \
problem — it usually means the httpfs extension was not active for this connection. \
Check that: (1) httpfs is installed and loads successfully — on first use it is fetched \
from the DuckDB extension repository, which needs outbound network access (pre-install \
it on the host if the server is offline); (2) the S3 endpoint, region, and credentials \
are configured so DuckDB can route the request through httpfs.",
)
} else {
format!(
"Unexpected: a local target hit the S3 filesystem sandbox path for {path_desc}. \
This may be a configuration bug — please report it.",
)
};
return Some(Diagnosis {
summary: "An S3 operation needed the local filesystem, which is disabled.".into(),
hint,
});
}
if lower.contains("signaturedoesnotmatch")
|| lower.contains("invalidaccesskeyid")
|| lower.contains("access denied")
|| lower.contains("accessdenied")
|| lower.contains("http error: 403")
|| lower.contains("http 403")
|| lower.contains("403 forbidden")
{
let (summary, hint) = if is_s3 {
(
"S3 rejected the request: authentication or permission failure.".to_string(),
format!(
"Check the storage's access_key, secret_key, and region. \
Confirm the bucket policy grants s3:PutObject (for writes) and s3:GetObject \
(for reads) for {path_desc}. \
Also verify the endpoint URL matches your S3-compatible service.",
),
)
} else {
(
"The destination rejected the write: permission denied.".to_string(),
format!(
"Check filesystem write permissions and ownership of the directory containing {path_desc}. \
Ensure the server process has write access to the storage root.",
),
)
};
return Some(Diagnosis { summary, hint });
}
if lower.contains("permission denied") || lower.contains("permission error") {
let (summary, hint) = if is_s3 {
(
"The S3 destination rejected the write (permission error).".to_string(),
format!(
"Confirm the S3 credentials can write to {path_desc} (s3:PutObject) \
and the bucket is not configured as read-only.",
),
)
} else {
(
"The local filesystem rejected the write (permission error).".to_string(),
format!(
"The OS denied writing {path_desc}. \
Check directory permissions, ownership, and that the storage root \
is writable by the server process.",
),
)
};
return Some(Diagnosis { summary, hint });
}
let is_extension_failure = is_s3
&& (lower.contains("failed to download extension")
|| (lower.contains("install") && lower.contains("httpfs"))
|| (lower.contains("extension") && (lower.contains("network") || lower.contains("http")))
|| (lower.contains("unable to connect")
&& (lower.contains("extension")
|| lower.contains("httpfs")
|| lower.contains("duckdb.org"))));
if is_extension_failure {
return Some(Diagnosis {
summary: "Could not load the httpfs/aws extension needed for S3 access.".into(),
hint: "The server needs outbound network access to the DuckDB extension repository \
to download httpfs/aws on first use. \
Alternatively, pre-install the extensions on the host with \
`duckdb -c \"INSTALL httpfs; INSTALL aws;\"`."
.into(),
});
}
let is_not_found = lower.contains("no files found")
|| (lower.contains("io error") && lower.contains("read"))
|| lower.contains("no such file")
|| lower.contains("cannot open file");
if is_not_found {
let hint = if is_s3 {
format!(
"Verify the source object exists at {path_desc} and the S3 credentials \
include s3:GetObject permission for that path.",
)
} else {
format!("Verify the source file exists at {path_desc} and is readable by the server process.",)
};
return Some(Diagnosis {
summary: "The source file could not be read.".into(),
hint,
});
}
let is_parse_error =
lower.contains("read_json") || lower.contains("read_csv") || lower.contains("json parse error");
if is_parse_error {
return Some(Diagnosis {
summary: "The source file could not be parsed as JSON/CSV.".into(),
hint: "DuckDB's auto-parser rejected the file. \
Check that it is valid newline-delimited JSON (.jsonl/.ndjson) \
or a consistent CSV/TSV; mixed schemas, ragged rows, or a BOM \
can trip auto-detection."
.into(),
});
}
None
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::config::S3Config;
use crate::sql::SqlTarget;
fn s3_target() -> SqlTarget {
SqlTarget::S3(S3Config {
endpoint: Some("http://localhost:9000".into()),
bucket: Some("mybucket".into()),
access_key: Some("key".into()),
secret_key: Some("secret".into()),
region: Some("us-east-1".into()),
force_path_style: true,
})
}
fn local_target() -> SqlTarget {
SqlTarget::Local {
root_path: PathBuf::from("/data/storage"),
}
}
#[test]
fn rule1_oom_out_of_memory() {
let raw =
"Out of Memory Error: failed to allocate data of size 116.6 MiB (463.8 MiB/488.2 MiB used)";
let diag = diagnose(&s3_target(), Some("s3://bucket/data.parquet"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("memory"),
"summary should mention memory: {}",
diag.summary
);
assert!(
diag.hint.contains("memory_limit"),
"hint should reference memory_limit: {}",
diag.hint
);
assert!(
diag.hint.contains("[sql].threads"),
"query OOM hint should mention lowering query threads: {}",
diag.hint
);
}
#[test]
fn rule1_oom_with_read_json_still_classified_as_oom() {
let raw = "Out of Memory Error: failed to allocate (read_json_auto internal buffer)";
let diag = diagnose(&local_target(), Some("/data/big.jsonl"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("memory"),
"OOM with read_json text must classify as OOM, not parse error: {}",
diag.summary
);
}
#[test]
fn rule1_oom_query_path_no_context() {
let raw =
"Out of Memory Error: failed to allocate data of size 512.0 MiB (510.0 MiB/512.0 MiB used)";
let diag = diagnose(&local_target(), None, raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("memory"),
"query-path OOM (context=None) must classify as OOM: {}",
diag.summary
);
assert!(
diag.hint.contains("memory_limit"),
"hint should reference memory_limit: {}",
diag.hint
);
assert!(
diag.hint.contains("the path"),
"hint must use generic path_desc when context is None: {}",
diag.hint
);
}
#[test]
fn rule1_failed_to_allocate_without_data_not_matched() {
let raw = "System error: failed to allocate buffer pool slot";
assert!(
diagnose(&local_target(), None, raw).is_none(),
"bare 'failed to allocate' without OOM context must return None"
);
}
#[test]
fn rule1_s3_localfilesystem_disabled() {
let raw = "Permission Error: File system LocalFileSystem has been disabled by configuration";
let diag = diagnose(&s3_target(), Some("s3://mybucket/data.parquet"), raw).unwrap();
assert!(
diag.summary.contains("local filesystem"),
"summary: {}",
diag.summary
);
assert!(diag.hint.contains("httpfs"), "hint: {}", diag.hint);
assert!(diag.hint.contains("endpoint"), "hint: {}", diag.hint);
assert!(
!diag.hint.to_lowercase().contains("read-only"),
"hint must not say read-only: {}",
diag.hint
);
assert!(
!diag.hint.to_lowercase().contains("not a local path"),
"hint must not second-guess the s3:// path: {}",
diag.hint
);
}
#[test]
fn rule1_local_target_localfilesystem_disabled_is_config_bug() {
let raw = "LocalFileSystem has been disabled by configuration";
let diag = diagnose(&local_target(), Some("s3://??/file"), raw).unwrap();
assert!(
diag.hint.to_lowercase().contains("bug"),
"hint: {}",
diag.hint
);
}
#[test]
fn rule2_signature_mismatch_s3() {
let raw = "HTTP Error: The request signature we calculated does not match the signature you provided: SignatureDoesNotMatch";
let diag = diagnose(&s3_target(), Some("s3://mybucket/out.parquet"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("authentication"),
"summary: {}",
diag.summary
);
assert!(diag.hint.contains("access_key"), "hint: {}", diag.hint);
}
#[test]
fn rule2_access_denied_s3() {
let raw = "HTTP 403 Forbidden: Access Denied";
let diag = diagnose(&s3_target(), Some("s3://mybucket/out.parquet"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("permission")
|| diag.summary.to_lowercase().contains("authentication"),
"{}",
diag.summary
);
}
#[test]
fn rule2_access_denied_local() {
let raw = "Access Denied: cannot write /data/storage/out.parquet";
let diag = diagnose(&local_target(), Some("/data/storage/out.parquet"), raw).unwrap();
assert!(
diag.hint.to_lowercase().contains("filesystem")
|| diag.hint.to_lowercase().contains("permission"),
"hint: {}",
diag.hint
);
}
#[test]
fn rule3_permission_local() {
let raw = "IO Error: Permission denied writing /data/out.parquet";
let diag = diagnose(&local_target(), Some("/data/out.parquet"), raw).unwrap();
assert!(
diag.hint.to_lowercase().contains("permission") || diag.hint.to_lowercase().contains("owner"),
"hint: {}",
diag.hint
);
assert!(!diag.hint.contains("s3:PutObject"), "{}", diag.hint);
}
#[test]
fn rule3_permission_s3() {
let raw = "IO Error: Permission denied for s3://mybucket/out.parquet";
let diag = diagnose(&s3_target(), Some("s3://mybucket/out.parquet"), raw).unwrap();
assert!(diag.hint.contains("s3:PutObject"), "hint: {}", diag.hint);
}
#[test]
fn rule4_extension_download_failed_s3() {
let raw = "Failed to download extension 'httpfs': network unreachable";
let diag = diagnose(&s3_target(), Some("s3://bucket/file"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("extension"),
"summary: {}",
diag.summary
);
assert!(
diag.hint.to_lowercase().contains("network") || diag.hint.to_lowercase().contains("install"),
"hint: {}",
diag.hint
);
}
#[test]
fn rule4_extension_failure_local_target_returns_none() {
let raw = "Failed to download extension 'httpfs': network unreachable";
assert!(
diagnose(&local_target(), Some("/data/file"), raw).is_none(),
"extension failure on local target should return None, not an httpfs hint"
);
}
#[test]
fn rule4_missing_extension_after_autoload_disabled() {
let raw = "Missing Extension Error: File s3://infographics/x.parquet requires the \
extension httpfs to be loaded\nPlease try installing and loading the httpfs \
extension by running:\nINSTALL httpfs;\nLOAD httpfs;";
let diag = diagnose(&s3_target(), Some("s3://infographics/x.parquet"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("extension"),
"summary should flag the extension problem: {}",
diag.summary
);
assert!(
!diag.summary.to_lowercase().contains("local filesystem"),
"should be Rule 5, not Rule 2: {}",
diag.summary
);
}
#[test]
fn rule5_no_files_found() {
let raw = "IO Error: No files found that match the pattern 's3://bucket/missing.jsonl'";
let diag = diagnose(&s3_target(), Some("s3://bucket/missing.jsonl"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("source file"),
"summary: {}",
diag.summary
);
assert!(diag.hint.contains("s3:GetObject"), "hint: {}", diag.hint);
}
#[test]
fn rule5_no_such_file_local() {
let raw = "IO Error: No such file or directory: /data/storage/missing.jsonl";
let diag = diagnose(&local_target(), Some("/data/storage/missing.jsonl"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("source file"),
"summary: {}",
diag.summary
);
}
#[test]
fn rule6_json_parse_error() {
let raw = "Invalid Input Error: Could not parse as JSON (read_json_auto): unexpected token";
let diag = diagnose(&s3_target(), Some("s3://bucket/data.jsonl"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("parsed"),
"{}",
diag.summary
);
assert!(
diag.hint.contains("jsonl") || diag.hint.contains("JSON"),
"{}",
diag.hint
);
}
#[test]
fn rule6_csv_parse_error() {
let raw = "Could not read_csv_auto: malformed CSV at row 42";
let diag = diagnose(&local_target(), Some("/data/data.csv"), raw).unwrap();
assert!(
diag.summary.to_lowercase().contains("parsed"),
"{}",
diag.summary
);
}
#[test]
fn rule6_conversion_error_sql_type_cast_not_matched() {
let raw = "Conversion Error: Could not convert string '2024-13-01' to DATE";
assert!(
diagnose(&s3_target(), None, raw).is_none(),
"generic type-cast Conversion Error should return None"
);
}
#[test]
fn fallback_unknown_returns_none() {
let raw = "Binder Error: Referenced column 'foo' not found in FROM clause";
assert!(
diagnose(&s3_target(), None, raw).is_none(),
"SQL binder errors should return None"
);
}
#[test]
fn fallback_syntax_error_returns_none() {
let raw = "Parser Error: syntax error at or near \"SELEKT\"";
assert!(diagnose(&local_target(), None, raw).is_none());
}
#[test]
fn rule1_takes_priority_over_rule3() {
let raw = "Permission Error: File system LocalFileSystem has been disabled by configuration";
let diag = diagnose(&s3_target(), Some("s3://b/f"), raw).unwrap();
assert!(
diag.summary.contains("local filesystem"),
"rule 2 should win over rule 4: {}",
diag.summary
);
}
#[test]
fn context_some_appears_in_hint() {
let raw = "IO Error: No files found that match the pattern 's3://bucket/path/to/file.jsonl'";
let ctx = "s3://bucket/path/to/file.jsonl";
let diag = diagnose(&s3_target(), Some(ctx), raw).unwrap();
assert!(
diag.hint.contains(ctx),
"context '{}' should appear in hint: {}",
ctx,
diag.hint
);
}
#[test]
fn context_none_shows_generic_path_desc() {
let raw = "IO Error: No files found that match the pattern 's3://bucket/missing.jsonl'";
let diag = diagnose(&s3_target(), None, raw).unwrap();
assert!(
!diag.hint.contains("<query>"),
"hint must not contain <query>: {}",
diag.hint
);
assert!(
diag.hint.contains("the path"),
"hint should say 'the path' when context is None: {}",
diag.hint
);
}
#[test]
fn rule2_bare_number_403_in_sql_not_matched() {
let raw = "Binder Error: WHERE id = 403 — column 'id' not found";
assert!(
diagnose(&s3_target(), None, raw).is_none(),
"bare '403' in SQL error should not trigger Rule 3"
);
}
#[test]
fn rule3_column_named_permission_not_matched() {
let raw = "Binder Error: Referenced column 'permission' not found in FROM clause";
assert!(
diagnose(&s3_target(), None, raw).is_none(),
"binder error with 'permission' column name should return None"
);
}
#[test]
fn rule4_s3_generic_endpoint_connection_failure_not_matched() {
let raw = "IOException: unable to connect to 'https://my-s3.internal:9000'";
assert!(
diagnose(&s3_target(), Some("s3://bucket/file"), raw).is_none(),
"generic S3 connection failure should not be classified as extension load failure (Rule 5)"
);
}
#[test]
fn rule6_malformed_s3_url_not_matched() {
let raw = "Invalid Input Error: Malformed S3 URL: missing bucket name";
assert!(
diagnose(&s3_target(), None, raw).is_none(),
"Malformed S3 URL without reader context should return None"
);
}
}