@@ -138,13 +138,21 @@ impl<'a> SearchBackendChain<'a> {
138138 query : & SearchQuery ,
139139 deadline : Instant ,
140140 first_attempt_budget : Option < Duration > ,
141+ fallback_budget_after_first : Option < Duration > ,
141142 ) -> Result < ChainedSearch , ToolError > {
142143 let backends = self
143144 . backends
144145 . iter ( )
145146 . map ( |backend| backend. as_ref ( ) )
146147 . collect :: < Vec < _ > > ( ) ;
147- run_backend_chain ( & backends, query, deadline, first_attempt_budget) . await
148+ run_backend_chain (
149+ & backends,
150+ query,
151+ deadline,
152+ first_attempt_budget,
153+ fallback_budget_after_first,
154+ )
155+ . await
148156 }
149157}
150158
@@ -165,14 +173,20 @@ const fn provider_native_is_available(capability_supported: bool, client_present
165173async fn run_backend_chain (
166174 backends : & [ & dyn SearchBackend ] ,
167175 query : & SearchQuery ,
168- deadline : Instant ,
176+ mut deadline : Instant ,
169177 first_attempt_budget : Option < Duration > ,
178+ fallback_budget_after_first : Option < Duration > ,
170179) -> Result < ChainedSearch , ToolError > {
171180 let mut degraded = Vec :: new ( ) ;
172181 let mut last_empty = None ;
173182 let mut attempted = Vec :: new ( ) ;
174183
175184 for ( index, backend) in backends. iter ( ) . enumerate ( ) {
185+ if index == 1
186+ && let Some ( fallback_budget) = fallback_budget_after_first
187+ {
188+ deadline = Instant :: now ( ) + fallback_budget. max ( Duration :: from_millis ( 1 ) ) ;
189+ }
176190 let remaining = deadline. saturating_duration_since ( Instant :: now ( ) ) ;
177191 if remaining. is_zero ( ) {
178192 break ;
@@ -208,20 +222,19 @@ async fn run_backend_chain(
208222 . and_then ( std:: convert:: identity) ;
209223
210224 match result {
211- Ok ( mut raw) if !raw. results . is_empty ( ) => {
212- degraded. append ( & mut raw. degraded ) ;
213- raw. degraded = degraded;
214- return Ok ( ChainedSearch {
215- raw,
216- capabilities : backend. capabilities ( ) ,
217- } ) ;
218- }
219225 Ok ( mut raw) => {
226+ let capabilities = backend. capabilities ( ) ;
227+ crate :: tools:: web_search:: apply_domain_constraints ( query, capabilities, & mut raw) ;
228+ if !raw. results . is_empty ( ) {
229+ degraded. append ( & mut raw. degraded ) ;
230+ raw. degraded = degraded;
231+ return Ok ( ChainedSearch { raw, capabilities } ) ;
232+ }
220233 degraded. push ( DegradedReason :: NoUsableResults {
221234 backend : backend_id,
222235 } ) ;
223236 degraded. append ( & mut raw. degraded ) ;
224- last_empty = Some ( ( raw, backend . capabilities ( ) ) ) ;
237+ last_empty = Some ( ( raw, capabilities) ) ;
225238 }
226239 Err ( error) if is_fail_closed ( & error) => return Err ( error) ,
227240 Err ( error) if backends. len ( ) == 1 => return Err ( error) ,
@@ -403,6 +416,12 @@ mod tests {
403416 result : Result < Vec < super :: super :: contract:: SearchResult > , ToolError > ,
404417 }
405418
419+ struct DeadlineBackend {
420+ id : BackendId ,
421+ observed_budget : Arc < Mutex < Option < Duration > > > ,
422+ delay : Duration ,
423+ }
424+
406425 #[ async_trait]
407426 impl SearchBackend for FakeBackend {
408427 fn id ( & self ) -> BackendId {
@@ -429,6 +448,35 @@ mod tests {
429448 }
430449 }
431450
451+ #[ async_trait]
452+ impl SearchBackend for DeadlineBackend {
453+ fn id ( & self ) -> BackendId {
454+ self . id
455+ }
456+
457+ fn capabilities ( & self ) -> QueryCapabilities {
458+ QueryCapabilities :: count_only ( )
459+ }
460+
461+ async fn search (
462+ & self ,
463+ _query : & SearchQuery ,
464+ deadline : Instant ,
465+ ) -> Result < BackendSearch , ToolError > {
466+ * self . observed_budget . lock ( ) . expect ( "budget lock" ) =
467+ Some ( deadline. saturating_duration_since ( Instant :: now ( ) ) ) ;
468+ tokio:: time:: sleep ( self . delay ) . await ;
469+ Ok ( BackendSearch {
470+ backend : self . id ,
471+ source : self . id . as_str ( ) . to_string ( ) ,
472+ backend_detail : None ,
473+ results : vec ! [ result( ) ] ,
474+ degraded : Vec :: new ( ) ,
475+ note : None ,
476+ } )
477+ }
478+ }
479+
432480 fn query ( ) -> SearchQuery {
433481 SearchQuery :: new ( "bounded chain" . to_string ( ) , 5 , None , Vec :: new ( ) , None )
434482 }
@@ -495,6 +543,7 @@ mod tests {
495543 & query ( ) ,
496544 Instant :: now ( ) + Duration :: from_secs ( 1 ) ,
497545 None ,
546+ None ,
498547 )
499548 . await
500549 . expect ( "fallback should succeed" ) ;
@@ -534,6 +583,7 @@ mod tests {
534583 & query ( ) ,
535584 Instant :: now ( ) + Duration :: from_secs ( 1 ) ,
536585 None ,
586+ None ,
537587 )
538588 . await
539589 . expect ( "final scrape fallback should succeed" ) ;
@@ -562,41 +612,11 @@ mod tests {
562612
563613 #[ tokio:: test]
564614 async fn first_attempt_budget_overrides_the_default_fair_share ( ) {
565- struct DeadlineBackend {
566- observed_budget : Arc < Mutex < Option < Duration > > > ,
567- }
568-
569- #[ async_trait]
570- impl SearchBackend for DeadlineBackend {
571- fn id ( & self ) -> BackendId {
572- BackendId :: Volcengine
573- }
574-
575- fn capabilities ( & self ) -> QueryCapabilities {
576- QueryCapabilities :: count_only ( )
577- }
578-
579- async fn search (
580- & self ,
581- _query : & SearchQuery ,
582- deadline : Instant ,
583- ) -> Result < BackendSearch , ToolError > {
584- * self . observed_budget . lock ( ) . expect ( "budget lock" ) =
585- Some ( deadline. saturating_duration_since ( Instant :: now ( ) ) ) ;
586- Ok ( BackendSearch {
587- backend : BackendId :: Volcengine ,
588- source : "volcengine" . to_string ( ) ,
589- backend_detail : None ,
590- results : vec ! [ result( ) ] ,
591- degraded : Vec :: new ( ) ,
592- note : None ,
593- } )
594- }
595- }
596-
597615 let observed_budget = Arc :: new ( Mutex :: new ( None ) ) ;
598616 let volcengine = DeadlineBackend {
617+ id : BackendId :: Volcengine ,
599618 observed_budget : Arc :: clone ( & observed_budget) ,
619+ delay : Duration :: ZERO ,
600620 } ;
601621 let fallback = FakeBackend {
602622 id : BackendId :: DuckDuckGo ,
@@ -608,6 +628,7 @@ mod tests {
608628 & query ( ) ,
609629 Instant :: now ( ) + Duration :: from_secs ( 2 ) ,
610630 Some ( first_attempt_budget) ,
631+ None ,
611632 )
612633 . await
613634 . expect ( "the first backend should complete inside its dedicated budget" ) ;
@@ -624,6 +645,37 @@ mod tests {
624645 assert ! ( observed <= first_attempt_budget) ;
625646 }
626647
648+ #[ tokio:: test]
649+ async fn provider_native_unused_budget_does_not_extend_fallback_deadline ( ) {
650+ let native = FakeBackend {
651+ id : BackendId :: ProviderNative ,
652+ result : Err ( ToolError :: execution_failed ( "native unavailable" ) ) ,
653+ } ;
654+ let observed_budget = Arc :: new ( Mutex :: new ( None ) ) ;
655+ let fallback = DeadlineBackend {
656+ id : BackendId :: DuckDuckGo ,
657+ observed_budget : Arc :: clone ( & observed_budget) ,
658+ delay : Duration :: from_millis ( 200 ) ,
659+ } ;
660+ let fallback_budget = Duration :: from_millis ( 30 ) ;
661+ let error = run_backend_chain (
662+ & [ & native, & fallback] ,
663+ & query ( ) ,
664+ Instant :: now ( ) + Duration :: from_millis ( 500 ) ,
665+ Some ( Duration :: from_millis ( 500 ) ) ,
666+ Some ( fallback_budget) ,
667+ )
668+ . await
669+ . expect_err ( "blocking fallback must stop at its own budget" ) ;
670+
671+ assert ! ( matches!( error, ToolError :: NotAvailable { .. } ) ) ;
672+ let observed = observed_budget
673+ . lock ( )
674+ . expect ( "budget lock" )
675+ . expect ( "fallback must observe a deadline" ) ;
676+ assert ! ( observed <= fallback_budget) ;
677+ }
678+
627679 #[ tokio:: test]
628680 async fn all_unavailable_returns_typed_error_with_backend_ids_only ( ) {
629681 let private_error = "secret provider response" ;
@@ -640,6 +692,7 @@ mod tests {
640692 & query ( ) ,
641693 Instant :: now ( ) + Duration :: from_secs ( 1 ) ,
642694 None ,
695+ None ,
643696 )
644697 . await
645698 . expect_err ( "all-down chain must fail" ) ;
@@ -689,6 +742,7 @@ mod tests {
689742 & query ( ) ,
690743 Instant :: now ( ) + Duration :: from_secs ( 1 ) ,
691744 None ,
745+ None ,
692746 )
693747 . await
694748 . expect_err ( "policy error must fail closed" ) ;
@@ -712,6 +766,7 @@ mod tests {
712766 & query ( ) ,
713767 Instant :: now ( ) + Duration :: from_secs ( 1 ) ,
714768 None ,
769+ None ,
715770 )
716771 . await
717772 . expect ( "empty API response should fall back" ) ;
@@ -729,4 +784,61 @@ mod tests {
729784 ]
730785 ) ;
731786 }
787+
788+ #[ tokio:: test]
789+ async fn domain_filtered_results_fall_back_before_chain_success ( ) {
790+ let native = FakeBackend {
791+ id : BackendId :: ProviderNative ,
792+ result : Ok ( vec ! [ SearchResult :: new(
793+ 1 ,
794+ "Outside source" . to_string( ) ,
795+ "https://outside.test/result" . to_string( ) ,
796+ None ,
797+ None ,
798+ ) ] ) ,
799+ } ;
800+ let configured = FakeBackend {
801+ id : BackendId :: Searxng ,
802+ result : Ok ( vec ! [ SearchResult :: new(
803+ 1 ,
804+ "Matching source" . to_string( ) ,
805+ "https://docs.rs/example/latest/example/" . to_string( ) ,
806+ None ,
807+ None ,
808+ ) ] ) ,
809+ } ;
810+ let constrained = SearchQuery :: new (
811+ "example docs" . to_string ( ) ,
812+ 5 ,
813+ None ,
814+ vec ! [ "docs.rs" . to_string( ) ] ,
815+ None ,
816+ ) ;
817+
818+ let response = run_backend_chain (
819+ & [ & native, & configured] ,
820+ & constrained,
821+ Instant :: now ( ) + Duration :: from_secs ( 1 ) ,
822+ None ,
823+ None ,
824+ )
825+ . await
826+ . expect ( "configured backend should satisfy the domain constraint" ) ;
827+
828+ assert_eq ! ( response. raw. backend, BackendId :: Searxng ) ;
829+ assert_eq ! ( response. raw. results. len( ) , 1 ) ;
830+ assert ! ( response. raw. degraded. iter( ) . any( |reason| matches!(
831+ reason,
832+ DegradedReason :: NoUsableResults {
833+ backend: BackendId :: ProviderNative
834+ }
835+ ) ) ) ;
836+ assert ! ( response. raw. degraded. iter( ) . any( |reason| matches!(
837+ reason,
838+ DegradedReason :: BackendFallback {
839+ from: BackendId :: ProviderNative ,
840+ to: BackendId :: Searxng
841+ }
842+ ) ) ) ;
843+ }
732844}
0 commit comments