@@ -1495,21 +1495,36 @@ impl FulaClient {
14951495 return Err ( ClientError :: MigrationLockHeld { bucket, expires_at } ) ;
14961496 }
14971497
1498- // For HEAD requests, S3 returns error code in x-amz-error-code header
1499- // since there's no response body
1500- let error_code = response
1498+ // S3-compat: error code lives in the `x-amz-error-code` header
1499+ // for HEAD responses (which carry no body) AND duplicated for
1500+ // every error response by our master (see fula-cli error.rs).
1501+ //
1502+ // Prefer the XML body's `<Message>` when present (it carries
1503+ // the master-side context, e.g., "bucket already exists: foo")
1504+ // and fall back to the header + a generic message only when
1505+ // the body is empty (HEAD response). The prior implementation
1506+ // unconditionally substituted `"Object not found"` whenever
1507+ // the header was present, swallowing every real error message
1508+ // and producing the famous "BucketAlreadyExists / Object not
1509+ // found" mismatch (filed in the 2026-05 E2E run).
1510+ let error_code_header = response
15011511 . headers ( )
15021512 . get ( "x-amz-error-code" )
15031513 . and_then ( |v| v. to_str ( ) . ok ( ) )
15041514 . map ( |s| s. to_string ( ) ) ;
15051515
15061516 let text = response. text ( ) . await . unwrap_or_default ( ) ;
15071517
1508- // If we have an error code header, use it; otherwise parse XML or use status
1509- if let Some ( code) = error_code {
1518+ if !text. is_empty ( ) {
1519+ return Err ( ClientError :: from_s3_xml ( & text, status. as_u16 ( ) ) ) ;
1520+ }
1521+
1522+ if let Some ( code) = error_code_header {
15101523 return Err ( ClientError :: S3Error {
15111524 code,
1512- message : "Object not found" . to_string ( ) ,
1525+ // Empty body (typically HEAD) — synthesize a minimal
1526+ // message from the HTTP status.
1527+ message : status. canonical_reason ( ) . unwrap_or ( "error" ) . to_string ( ) ,
15131528 request_id : None ,
15141529 } ) ;
15151530 }
0 commit comments