faces: fix PathExcluder false-positive + cover face_client/crop in tests
PathExcluder was iterating every component of the absolute path,
including the system prefix. Two of the existing memories tests had
been failing on master because tempdir() lives under /tmp on Linux
and a pattern like "tmp" then matched the system /tmp component
rather than anything the user actually asked to exclude. Phase 3's
file-watch hook will use the same code to skip @eaDir / .thumbnails
under each library's BASE_PATH, so the bug would hide every photo
on a host whose BASE_PATH passes through a directory named the same
as a user pattern.
Fix: store base in PathExcluder and strip it before scanning
components. A path that lives outside base falls through to the
no-match branch (defensive — nothing legit hits that today).
Also extracted the face_client error classification into a pure
classify_error_response(status, body) so the marker-row contract
with Apollo (422 → Permanent / 'failed', 5xx → Transient / defer)
is unit-testable without spinning up an HTTP server.
New tests:
memories::tests::test_path_excluder_* — 2 previously
failing tests now pass.
ai::face_client::tests::classify_* — 4 cases:
422 decode_failed → Permanent, 503 cuda_oom → Transient
(handles both string and {code:..} detail shapes), 5xx →
Transient + other 4xx → Permanent, unparseable HTML body still
classifies on status.
faces::tests::crop_* — 3 cases:
invalid bbox rejected, valid bbox round-trips through JPEG
decode, corner crop with 10% padding clamps inside source.
cargo test --lib: 165 passed / 0 failed (was 156 / 2 failed).
cargo fmt and clippy on new code clean. The remaining
sort_by clippy warnings in pre-existing files (memories.rs,
files.rs, exif.rs) are unrelated and present on master.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -257,56 +257,114 @@ impl FaceClient {
|
||||
}
|
||||
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
// Apollo encodes its error class in the JSON body's `detail`. Try
|
||||
// to parse it; fall back to status-only classification.
|
||||
let detail_code = serde_json::from_str::<serde_json::Value>(&body_text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
// detail can be a string ("decode_failed") or an object
|
||||
// ({"code": "cuda_oom", ...}) depending on the endpoint
|
||||
// and Apollo's response shape — handle both.
|
||||
v.get("detail")
|
||||
.and_then(|d| d.as_str().map(str::to_string))
|
||||
.or_else(|| {
|
||||
v.get("detail")
|
||||
.and_then(|d| d.get("code"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
|
||||
return Err(FaceDetectError::Permanent(anyhow::anyhow!(
|
||||
"face detect 422 {}: {}",
|
||||
detail_code,
|
||||
body_text
|
||||
)));
|
||||
}
|
||||
if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Err(FaceDetectError::Transient(anyhow::anyhow!(
|
||||
"face detect 503 {}: {}",
|
||||
detail_code,
|
||||
body_text
|
||||
)));
|
||||
}
|
||||
// Any other 4xx: be conservative and treat as Permanent so we
|
||||
// don't loop forever on a stable rejection. Any other 5xx:
|
||||
// Transient — likely intermittent.
|
||||
if status.is_client_error() {
|
||||
Err(FaceDetectError::Permanent(anyhow::anyhow!(
|
||||
"face detect {} {}: {}",
|
||||
status.as_u16(),
|
||||
detail_code,
|
||||
body_text
|
||||
)))
|
||||
} else {
|
||||
Err(FaceDetectError::Transient(anyhow::anyhow!(
|
||||
"face detect {} {}: {}",
|
||||
status.as_u16(),
|
||||
detail_code,
|
||||
body_text
|
||||
)))
|
||||
}
|
||||
Err(classify_error_response(status.as_u16(), &body_text))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an Apollo HTTP error response to a FaceDetectError. Pulled out as a
|
||||
/// pure function so the marker-row contract (422 → Permanent, 503 →
|
||||
/// Transient) is unit-testable without spinning up an HTTP server.
|
||||
fn classify_error_response(status: u16, body_text: &str) -> FaceDetectError {
|
||||
// Apollo encodes its error class in the JSON body's `detail`. Try to
|
||||
// parse it; fall back to status-only classification.
|
||||
let detail_code = serde_json::from_str::<serde_json::Value>(body_text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
// detail can be a string ("decode_failed") or an object
|
||||
// ({"code": "cuda_oom", ...}) depending on the endpoint and
|
||||
// Apollo's response shape — handle both.
|
||||
v.get("detail")
|
||||
.and_then(|d| d.as_str().map(str::to_string))
|
||||
.or_else(|| {
|
||||
v.get("detail")
|
||||
.and_then(|d| d.get("code"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if status == 422 {
|
||||
return FaceDetectError::Permanent(anyhow::anyhow!(
|
||||
"face detect 422 {}: {}",
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
if status == 503 {
|
||||
return FaceDetectError::Transient(anyhow::anyhow!(
|
||||
"face detect 503 {}: {}",
|
||||
detail_code,
|
||||
body_text
|
||||
));
|
||||
}
|
||||
// Any other 4xx: be conservative and treat as Permanent so we don't
|
||||
// loop forever on a stable rejection. Any other 5xx: Transient —
|
||||
// likely intermittent.
|
||||
if (400..500).contains(&status) {
|
||||
FaceDetectError::Permanent(anyhow::anyhow!(
|
||||
"face detect {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
))
|
||||
} else {
|
||||
FaceDetectError::Transient(anyhow::anyhow!(
|
||||
"face detect {} {}: {}",
|
||||
status,
|
||||
detail_code,
|
||||
body_text
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is_permanent(e: &FaceDetectError) -> bool {
|
||||
matches!(e, FaceDetectError::Permanent(_))
|
||||
}
|
||||
fn is_transient(e: &FaceDetectError) -> bool {
|
||||
matches!(e, FaceDetectError::Transient(_))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_422_decode_failed_is_permanent() {
|
||||
// Permanent → ImageApi marks status='failed' and stops retrying.
|
||||
let e = classify_error_response(422, r#"{"detail":"decode_failed: bad bytes"}"#);
|
||||
assert!(is_permanent(&e), "422 decode_failed must be Permanent");
|
||||
assert!(format!("{e}").contains("decode_failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_503_cuda_oom_is_transient() {
|
||||
// Transient → ImageApi must NOT write a marker so the next scan
|
||||
// retries. The detail.code is nested in an object rather than a
|
||||
// bare string; the parser handles both.
|
||||
let e = classify_error_response(
|
||||
503,
|
||||
r#"{"detail":{"code":"cuda_oom","error":"out of memory"}}"#,
|
||||
);
|
||||
assert!(is_transient(&e), "503 cuda_oom must be Transient");
|
||||
assert!(format!("{e}").contains("cuda_oom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_500_is_transient_other_4xx_is_permanent() {
|
||||
// Conservative split: 5xx defers (intermittent), other 4xx
|
||||
// is treated as a stable rejection so we don't loop forever.
|
||||
assert!(is_transient(&classify_error_response(500, "")));
|
||||
assert!(is_transient(&classify_error_response(502, "{}")));
|
||||
assert!(is_permanent(&classify_error_response(400, "{}")));
|
||||
assert!(is_permanent(&classify_error_response(404, "{}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_handles_unparseable_body() {
|
||||
// Apollo can return non-JSON on misroute / proxy errors; the
|
||||
// classifier must still produce a useful variant.
|
||||
let e = classify_error_response(503, "<html>nginx</html>");
|
||||
assert!(is_transient(&e));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user