@@ -682,6 +682,26 @@ impl S3RequestError {
682682 }
683683 }
684684
685+ fn not_found ( ) -> Self {
686+ Self {
687+ status : StatusCode :: NOT_FOUND ,
688+ code : "NoSuchKey" ,
689+ message : "The specified key does not exist" . into ( ) ,
690+ failure : FailureReason :: NotFound ,
691+ content_range : None ,
692+ }
693+ }
694+
695+ fn precondition_failed ( ) -> Self {
696+ Self {
697+ status : StatusCode :: PRECONDITION_FAILED ,
698+ code : "PreconditionFailed" ,
699+ message : "At least one precondition failed" . into ( ) ,
700+ failure : FailureReason :: Precondition ,
701+ content_range : None ,
702+ }
703+ }
704+
685705 fn origin_unavailable ( message : impl Into < String > ) -> Self {
686706 let _ = message. into ( ) ;
687707 Self {
@@ -898,6 +918,12 @@ impl S3Adapter {
898918 . head ( & object, & conditions)
899919 . await
900920 . map_err ( S3RequestError :: origin_unavailable) ?;
921+ if metadata. status == 404 {
922+ return Err ( S3RequestError :: not_found ( ) ) ;
923+ }
924+ if metadata. status == 412 {
925+ return Err ( S3RequestError :: precondition_failed ( ) ) ;
926+ }
901927 if !( 200 ..300 ) . contains ( & metadata. status ) {
902928 return Ok ( raw_response (
903929 metadata,
@@ -1071,6 +1097,8 @@ impl S3Adapter {
10711097#[ cfg( test) ]
10721098mod tests {
10731099 use super :: * ;
1100+ use std:: collections:: VecDeque ;
1101+ use std:: sync:: atomic:: { AtomicBool , AtomicUsize , Ordering } ;
10741102 use std:: sync:: Mutex ;
10751103
10761104 use axum:: body:: to_bytes;
@@ -1090,11 +1118,57 @@ mod tests {
10901118 Err ( CacheReadError :: Unavailable ( message) ) => {
10911119 Err ( CacheReadError :: Unavailable ( message. clone ( ) ) )
10921120 }
1121+ Err ( CacheReadError :: Timeout ( message) ) => {
1122+ Err ( CacheReadError :: Timeout ( message. clone ( ) ) )
1123+ }
1124+ Err ( CacheReadError :: Protocol ( message) ) => {
1125+ Err ( CacheReadError :: Protocol ( message. clone ( ) ) )
1126+ }
10931127 _ => unreachable ! ( "test cache response" ) ,
10941128 }
10951129 }
10961130 }
10971131
1132+ struct DropSignal ( Arc < AtomicBool > ) ;
1133+
1134+ impl Drop for DropSignal {
1135+ fn drop ( & mut self ) {
1136+ self . 0 . store ( true , Ordering :: SeqCst ) ;
1137+ }
1138+ }
1139+
1140+ struct DemandCache {
1141+ polls : Arc < AtomicUsize > ,
1142+ dropped : Arc < AtomicBool > ,
1143+ }
1144+
1145+ impl DemandCache {
1146+ fn new ( ) -> Arc < Self > {
1147+ Arc :: new ( Self {
1148+ polls : Arc :: new ( AtomicUsize :: new ( 0 ) ) ,
1149+ dropped : Arc :: new ( AtomicBool :: new ( false ) ) ,
1150+ } )
1151+ }
1152+ }
1153+
1154+ impl S3Cache for DemandCache {
1155+ fn stream ( & self , _request : S3CacheRequest < ' _ > ) -> Result < CacheStream , CacheReadError > {
1156+ let chunks = VecDeque :: from ( [ Bytes :: from_static ( b"abc" ) , Bytes :: from_static ( b"def" ) ] ) ;
1157+ let polls = Arc :: clone ( & self . polls ) ;
1158+ let guard = DropSignal ( Arc :: clone ( & self . dropped ) ) ;
1159+ Ok ( Box :: pin ( futures:: stream:: unfold (
1160+ ( chunks, guard) ,
1161+ move |( mut chunks, guard) | {
1162+ let polls = Arc :: clone ( & polls) ;
1163+ async move {
1164+ polls. fetch_add ( 1 , Ordering :: SeqCst ) ;
1165+ chunks. pop_front ( ) . map ( |chunk| ( Ok ( chunk) , ( chunks, guard) ) )
1166+ }
1167+ } ,
1168+ ) ) )
1169+ }
1170+ }
1171+
10981172 struct MockOrigin {
10991173 lists : Mutex < Vec < ListRequest > > ,
11001174 }
@@ -1171,6 +1245,40 @@ mod tests {
11711245 }
11721246 }
11731247
1248+ struct UnavailableOrigin ;
1249+
1250+ #[ async_trait]
1251+ impl S3Origin for UnavailableOrigin {
1252+ async fn head (
1253+ & self ,
1254+ _object : & ObjectId ,
1255+ _conditions : & [ ( String , String ) ] ,
1256+ ) -> Result < HttpResponse , String > {
1257+ Err ( "connect failed at https://s3.example/?secret=credential" . into ( ) )
1258+ }
1259+
1260+ async fn get (
1261+ & self ,
1262+ _object : & ObjectId ,
1263+ _range : Option < ( u64 , u64 ) > ,
1264+ _conditions : & [ ( String , String ) ] ,
1265+ ) -> Result < HttpStreamResponse , String > {
1266+ unreachable ! ( "HEAD failure must stop GET dispatch" )
1267+ }
1268+
1269+ async fn list (
1270+ & self ,
1271+ _bucket : & str ,
1272+ _prefix : & str ,
1273+ _delimiter : Option < & str > ,
1274+ _continuation_token : Option < & str > ,
1275+ _max_keys : u32 ,
1276+ _encoding_type : Option < & str > ,
1277+ ) -> Result < HttpResponse , String > {
1278+ unreachable ! ( "test only dispatches object reads" )
1279+ }
1280+ }
1281+
11741282 fn adapter ( cache : MockCache , origin : Arc < MockOrigin > ) -> S3Adapter {
11751283 S3Adapter :: new (
11761284 S3AdapterConfig :: path_style ( "localhost" ) ,
@@ -1278,6 +1386,78 @@ mod tests {
12781386 ) ;
12791387 }
12801388
1389+ #[ tokio:: test]
1390+ async fn timeout_falls_back_but_protocol_failure_is_terminal ( ) {
1391+ let timeout = adapter (
1392+ MockCache {
1393+ response : Err ( CacheReadError :: Timeout ( "slow worker" . into ( ) ) ) ,
1394+ } ,
1395+ MockOrigin :: new ( ) ,
1396+ )
1397+ . handle ( request ( axum:: http:: Method :: GET , "/bucket/key" ) , context ( ) )
1398+ . await ;
1399+ assert_eq ! ( timeout. response. status( ) , StatusCode :: OK ) ;
1400+ assert_eq ! ( timeout. outcome, GatewayOutcome :: Fallback ) ;
1401+
1402+ let protocol = adapter (
1403+ MockCache {
1404+ response : Err ( CacheReadError :: Protocol ( "bad frame" . into ( ) ) ) ,
1405+ } ,
1406+ MockOrigin :: new ( ) ,
1407+ )
1408+ . handle ( request ( axum:: http:: Method :: GET , "/bucket/key" ) , context ( ) )
1409+ . await ;
1410+ assert_eq ! (
1411+ protocol. response. status( ) ,
1412+ StatusCode :: INTERNAL_SERVER_ERROR
1413+ ) ;
1414+ assert_eq ! ( protocol. outcome, GatewayOutcome :: Failed ) ;
1415+ }
1416+
1417+ #[ tokio:: test]
1418+ async fn cache_stream_respects_backpressure_and_cancellation ( ) {
1419+ let cache = DemandCache :: new ( ) ;
1420+ let response = S3Adapter :: new (
1421+ S3AdapterConfig :: path_style ( "localhost" ) ,
1422+ Arc :: clone ( & cache) as Arc < dyn S3Cache > ,
1423+ MockOrigin :: new ( ) ,
1424+ )
1425+ . unwrap ( )
1426+ . handle ( request ( axum:: http:: Method :: GET , "/bucket/key" ) , context ( ) )
1427+ . await ;
1428+
1429+ assert_eq ! ( cache. polls. load( Ordering :: SeqCst ) , 1 ) ;
1430+ tokio:: task:: yield_now ( ) . await ;
1431+ assert_eq ! ( cache. polls. load( Ordering :: SeqCst ) , 1 ) ;
1432+ let mut body = response. response . into_body ( ) . into_data_stream ( ) ;
1433+ assert_eq ! (
1434+ body. next( ) . await . unwrap( ) . unwrap( ) ,
1435+ Bytes :: from_static( b"abc" )
1436+ ) ;
1437+ assert_eq ! ( cache. polls. load( Ordering :: SeqCst ) , 1 ) ;
1438+ drop ( body) ;
1439+ assert ! ( cache. dropped. load( Ordering :: SeqCst ) ) ;
1440+ }
1441+
1442+ #[ tokio:: test]
1443+ async fn origin_outage_returns_a_sanitized_s3_error ( ) {
1444+ let response = S3Adapter :: new (
1445+ S3AdapterConfig :: path_style ( "localhost" ) ,
1446+ Arc :: new ( MockCache {
1447+ response : Ok ( Vec :: new ( ) ) ,
1448+ } ) ,
1449+ Arc :: new ( UnavailableOrigin ) ,
1450+ )
1451+ . unwrap ( )
1452+ . handle ( request ( axum:: http:: Method :: GET , "/bucket/key" ) , context ( ) )
1453+ . await ;
1454+
1455+ assert_eq ! ( response. response. status( ) , StatusCode :: SERVICE_UNAVAILABLE ) ;
1456+ let body = to_bytes ( response. response . into_body ( ) , 4096 ) . await . unwrap ( ) ;
1457+ assert ! ( !body. windows( 6 ) . any( |window| window == b"secret" ) ) ;
1458+ assert ! ( !body. windows( 10 ) . any( |window| window == b"credential" ) ) ;
1459+ }
1460+
12811461 #[ tokio:: test]
12821462 async fn forwards_bounded_list_v2_parameters ( ) {
12831463 let origin = MockOrigin :: new ( ) ;
0 commit comments