1616//! Shared with the ladder: the same feed URLs, the same
1717//! `quote::bid_price`/`ask_price` spreads, the same [`crate::eip712`] Permit2
1818//! digest and the same order-bytes encoder — one pricing and signing story,
19- //! two distribution channels. RFQ caps staleness at
20- //! [`crate::config::RFQ_MAX_STALENESS_SECS`] (60s ) so a 900s ladder template
21- //! cannot keep firm quotes on a 14-minute-old print; the ladder still uses
19+ //! two distribution channels. RFQ tightens staleness per feed (see
20+ //! [`crate::config::rfq_staleness_secs`] ) so a 900s ladder template cannot
21+ //! keep firm quotes on a 14-minute-old print; the ladder still uses
2222//! `[feed].staleness_secs` as written. Deliberately NOT shared: the tick loop
2323//! (RFQ runs its own 1 s cadence and its own price cache so a slow ladder tick
2424//! can't blow the reply budget) and the nonce ledger (RFQ nonces live in a
@@ -44,7 +44,7 @@ use tokio_tungstenite::tungstenite::protocol::Message;
4444use tracing:: { debug, error, info, warn} ;
4545
4646use crate :: closer:: executor:: { encode_allowance, encode_balance_of} ;
47- use crate :: config:: { rfq_staleness_secs , Config } ;
47+ use crate :: config:: { rfq_staleness_secs_for_pool , Config } ;
4848use crate :: eip712:: permit2_digest;
4949use crate :: feed:: { HttpFeed , PriceFeed , Quote } ;
5050use crate :: rpc:: Wallet ;
@@ -74,7 +74,6 @@ pub struct RfqRuntime {
7474 permit2 : Address ,
7575 reactor : Address ,
7676 validation_contract : Address ,
77- staleness_secs : u64 ,
7877 rpc_url : String ,
7978 indexer_url : String ,
8079 books : Vec < CorridorBook > ,
@@ -197,7 +196,7 @@ fn build_runtime(
197196 let books = cfg
198197 . pools
199198 . iter ( )
200- . map ( |p| book_from_pool ( p, & cfg. feed . url ) )
199+ . map ( |p| book_from_pool ( p, & cfg. feed . url , rfq_staleness_secs_for_pool ( & cfg . feed , p ) ) )
201200 . collect :: < anyhow:: Result < Vec < _ > > > ( ) ?
202201 . into_iter ( )
203202 . flatten ( )
@@ -214,7 +213,6 @@ fn build_runtime(
214213 . validation_contract
215214 . parse ( )
216215 . context ( "invalid [rfq].validation_contract" ) ?,
217- staleness_secs : rfq_staleness_secs ( cfg. feed . staleness_secs ) ,
218216 rpc_url : cfg. rpc_url . clone ( ) ,
219217 indexer_url : cfg. indexer_url . clone ( ) ,
220218 books,
@@ -340,11 +338,11 @@ async fn run(rt: RfqRuntime) {
340338 Reservations :: new ( )
341339 }
342340 } ;
343- let mut backoff_secs = 1u64 ;
341+ let mut backoff = Backoff :: default ( ) ;
344342 loop {
345343 match session:: connect_and_auth ( & rt. url , & rt. api_key , & rt. maker_id , & rt. signer ) . await {
346344 Ok ( authed) => {
347- backoff_secs = 1 ;
345+ backoff . reset ( ) ;
348346 let ( err, ledger) = session_loop (
349347 & rt,
350348 & prices,
@@ -354,17 +352,92 @@ async fn run(rt: RfqRuntime) {
354352 )
355353 . await ;
356354 reservations = ledger;
357- warn ! (
358- error = %format!( "{err:#}" ) ,
359- "RFQ session ended (superseded, closed, or failed); reconnecting"
360- ) ;
355+ if session:: is_handover ( & err) {
356+ info ! (
357+ detail = %format!( "{err:#}" ) ,
358+ "RFQ venue handed this session over; reconnecting immediately"
359+ ) ;
360+ } else {
361+ warn ! (
362+ error = %format!( "{err:#}" ) ,
363+ "RFQ session ended (superseded, closed, or failed); reconnecting"
364+ ) ;
365+ }
366+ backoff. note ( & err) ;
361367 }
362368 Err ( e) => {
363- warn ! ( error = %format!( "{e:#}" ) , "RFQ connect/auth failed" ) ;
369+ if session:: is_handover ( & e) {
370+ debug ! ( detail = %format!( "{e:#}" ) , "venue redirected us; retrying immediately" ) ;
371+ } else {
372+ warn ! ( error = %format!( "{e:#}" ) , "RFQ connect/auth failed" ) ;
373+ }
374+ backoff. note ( & e) ;
364375 }
365376 }
366- tokio:: time:: sleep ( std:: time:: Duration :: from_secs ( backoff_secs) ) . await ;
367- backoff_secs = ( backoff_secs * 2 ) . min ( 30 ) ;
377+ tokio:: time:: sleep ( backoff. next_delay ( ) ) . await ;
378+ }
379+ }
380+
381+ /// Reconnect pacing, with a fast lane for handovers.
382+ ///
383+ /// Two different failures wear the same shape here. A venue that is genuinely
384+ /// down wants exponential backoff, so a fleet of bots doesn't hammer it back
385+ /// into the ground. A venue that is *moving* — a deploy handing sockets from
386+ /// the outgoing task to the warm incoming one — wants no delay at all: the
387+ /// replacement is already accepting, and every second spent sleeping is a
388+ /// second the corridor reports no makers for no reason. Backing off through a
389+ /// deploy is most of what used to make one cost minutes.
390+ ///
391+ /// The fast lane is bounded in attempts rather than time, because the thing it
392+ /// is riding out is "the ALB handed me the wrong one of N tasks", which is a
393+ /// couple of retries, not a wait.
394+ struct Backoff {
395+ secs : u64 ,
396+ fast_attempts : u32 ,
397+ }
398+
399+ /// Immediate retries allowed after a handover before normal backoff resumes.
400+ /// Generous for a two-task overlap; still finite, so a venue stuck refusing
401+ /// every socket can't be spun on forever.
402+ const HANDOVER_FAST_RETRIES : u32 = 20 ;
403+
404+ /// Pause between fast-lane retries. Long enough not to spin, short enough that
405+ /// a handover is invisible next to a WebSocket handshake.
406+ const HANDOVER_RETRY_MS : u64 = 250 ;
407+
408+ impl Default for Backoff {
409+ fn default ( ) -> Self {
410+ Self {
411+ secs : 1 ,
412+ fast_attempts : 0 ,
413+ }
414+ }
415+ }
416+
417+ impl Backoff {
418+ /// A session that ran clears both lanes.
419+ fn reset ( & mut self ) {
420+ self . secs = 1 ;
421+ self . fast_attempts = 0 ;
422+ }
423+
424+ /// Record why the last attempt ended, opening or closing the fast lane.
425+ fn note ( & mut self , err : & anyhow:: Error ) {
426+ if session:: is_handover ( err) {
427+ self . fast_attempts = self . fast_attempts . saturating_add ( 1 ) ;
428+ } else {
429+ self . fast_attempts = 0 ;
430+ }
431+ }
432+
433+ /// How long to wait before the next attempt, advancing the backoff.
434+ fn next_delay ( & mut self ) -> std:: time:: Duration {
435+ if self . fast_attempts > 0 && self . fast_attempts <= HANDOVER_FAST_RETRIES {
436+ return std:: time:: Duration :: from_millis ( HANDOVER_RETRY_MS ) ;
437+ }
438+ let delay = std:: time:: Duration :: from_secs ( self . secs ) ;
439+ self . secs = ( self . secs * 2 ) . min ( 30 ) ;
440+ delay
368441 }
369442}
370443
@@ -643,7 +716,6 @@ async fn session_loop_inner(
643716 permit2 : rt. permit2 ,
644717 reactor : rt. reactor ,
645718 validation_contract : rt. validation_contract ,
646- staleness_secs : rt. staleness_secs ,
647719 signer : rt. signer . clone ( ) ,
648720 } ;
649721
@@ -720,8 +792,14 @@ async fn session_loop_inner(
720792 stream. send( Message :: Pong ( payload) ) . await . context( "sending pong" ) ?;
721793 }
722794 Message :: Close ( reason) => {
723- warn!( ?reason, "venue sent close (possibly a superseding session)" ) ;
724- anyhow:: bail!( "venue closed the session: {reason:?}" ) ;
795+ if session:: handover_close(
796+ reason. as_ref( ) . map( |f| u16 :: from( f. code) ) . unwrap_or( 0 ) ,
797+ ) {
798+ info!( ?reason, "venue handing this session over; reconnecting now" ) ;
799+ } else {
800+ warn!( ?reason, "venue sent close (possibly a superseding session)" ) ;
801+ }
802+ return Err ( session:: close_error( & reason) ) ;
725803 }
726804 _ => { }
727805 }
@@ -787,7 +865,6 @@ struct Engine {
787865 permit2 : Address ,
788866 reactor : Address ,
789867 validation_contract : Address ,
790- staleness_secs : u64 ,
791868 signer : DynSigner ,
792869}
793870
@@ -802,7 +879,7 @@ impl Engine {
802879 . iter ( )
803880 . filter_map ( |book| {
804881 let quote = prices. get ( & book. feed_url ) ?;
805- if is_stale ( quote. timestamp , now_secs, self . staleness_secs )
882+ if is_stale ( quote. timestamp , now_secs, book . staleness_secs )
806883 || !is_price_usable ( quote. price )
807884 {
808885 return None ;
@@ -900,7 +977,9 @@ impl Engine {
900977 let Some ( quote) = prices. get ( & book. feed_url ) else {
901978 return reject ( RejectReason :: StaleFeed ) ;
902979 } ;
903- if is_stale ( quote. timestamp , now_secs, self . staleness_secs ) || !is_price_usable ( quote. price )
980+ // The book's own window, not the runtime's: this is the firm-quote
981+ // gate, so it must match the one the levels were published under.
982+ if is_stale ( quote. timestamp , now_secs, book. staleness_secs ) || !is_price_usable ( quote. price )
904983 {
905984 return reject ( RejectReason :: StaleFeed ) ;
906985 }
@@ -1131,6 +1210,78 @@ mod tests {
11311210 const COLLATERAL : & str = "0x0000000000000000000000000000000000000001" ;
11321211 const DEBT : & str = "0x0000000000000000000000000000000000000002" ;
11331212
1213+ fn handover ( code : u16 ) -> anyhow:: Error {
1214+ anyhow:: Error :: new ( session:: VenueHandover {
1215+ code,
1216+ reason : "test" . into ( ) ,
1217+ } )
1218+ }
1219+
1220+ #[ test]
1221+ fn only_the_redirect_codes_count_as_a_handover ( ) {
1222+ assert ! ( session:: handover_close( 4005 ) , "not-the-engine redirects" ) ;
1223+ assert ! ( session:: handover_close( 4006 ) , "draining redirects" ) ;
1224+ // Everything in 4000-4004 is the maker's problem, not the task's:
1225+ // reconnecting instantly would hammer the venue with a credential it
1226+ // has already rejected.
1227+ for code in [ 0u16 , 1006 , 4000 , 4001 , 4002 , 4003 , 4004 ] {
1228+ assert ! (
1229+ !session:: handover_close( code) ,
1230+ "{code} must not open the fast lane"
1231+ ) ;
1232+ }
1233+ }
1234+
1235+ #[ test]
1236+ fn is_handover_sees_through_added_context ( ) {
1237+ let wrapped = handover ( 4006 ) . context ( "connecting to venue" ) ;
1238+ assert ! ( session:: is_handover( & wrapped) ) ;
1239+ assert ! ( !session:: is_handover( & anyhow:: anyhow!(
1240+ "503 Service Unavailable"
1241+ ) ) ) ;
1242+ }
1243+
1244+ #[ test]
1245+ fn a_handover_retries_immediately_while_a_failure_backs_off ( ) {
1246+ let mut b = Backoff :: default ( ) ;
1247+
1248+ // A deploy handover: no sleeping. This is the whole point — the warm
1249+ // replacement is already accepting sockets.
1250+ b. note ( & handover ( 4006 ) ) ;
1251+ assert_eq ! ( b. next_delay( ) , std:: time:: Duration :: from_millis( 250 ) ) ;
1252+ b. note ( & handover ( 4005 ) ) ;
1253+ assert_eq ! ( b. next_delay( ) , std:: time:: Duration :: from_millis( 250 ) ) ;
1254+
1255+ // A real outage goes back to exponential, and the handover retries it
1256+ // just made must not have eaten the budget.
1257+ b. note ( & anyhow:: anyhow!( "503" ) ) ;
1258+ assert_eq ! ( b. next_delay( ) , std:: time:: Duration :: from_secs( 1 ) ) ;
1259+ b. note ( & anyhow:: anyhow!( "503" ) ) ;
1260+ assert_eq ! ( b. next_delay( ) , std:: time:: Duration :: from_secs( 2 ) ) ;
1261+ }
1262+
1263+ #[ test]
1264+ fn the_fast_lane_is_finite ( ) {
1265+ let mut b = Backoff :: default ( ) ;
1266+ for _ in 0 ..HANDOVER_FAST_RETRIES {
1267+ b. note ( & handover ( 4005 ) ) ;
1268+ assert_eq ! ( b. next_delay( ) , std:: time:: Duration :: from_millis( 250 ) ) ;
1269+ }
1270+ // A venue refusing every socket must not be spun on forever.
1271+ b. note ( & handover ( 4005 ) ) ;
1272+ assert_eq ! ( b. next_delay( ) , std:: time:: Duration :: from_secs( 1 ) ) ;
1273+ }
1274+
1275+ #[ test]
1276+ fn a_session_that_ran_clears_both_lanes ( ) {
1277+ let mut b = Backoff :: default ( ) ;
1278+ b. note ( & anyhow:: anyhow!( "503" ) ) ;
1279+ let _ = b. next_delay ( ) ;
1280+ let _ = b. next_delay ( ) ;
1281+ b. reset ( ) ;
1282+ assert_eq ! ( b. next_delay( ) , std:: time:: Duration :: from_secs( 1 ) ) ;
1283+ }
1284+
11341285 fn test_engine ( ) -> Engine {
11351286 let key = SigningKey :: from_slice (
11361287 & alloy_primitives:: hex:: decode (
@@ -1151,6 +1302,7 @@ mod tests {
11511302 buy_capacity_debt: Some ( RfqCapacity :: Exact ( U256 :: from( 1_500_000_000u64 ) ) ) ,
11521303 sell_capacity_collateral: Some ( RfqCapacity :: Exact ( U256 :: from( 1_500_000_000u64 ) ) ) ,
11531304 feed_url: "http://feed" . into( ) ,
1305+ staleness_secs: 240 ,
11541306 } ] ,
11551307 reservations : Reservations :: new ( ) ,
11561308 inventory : {
@@ -1175,7 +1327,6 @@ mod tests {
11751327 validation_contract : "0x00000000000000000000000000000000000000f1"
11761328 . parse ( )
11771329 . unwrap ( ) ,
1178- staleness_secs : 900 ,
11791330 signer : Arc :: new ( LocalSigner :: new ( key) ) ,
11801331 }
11811332 }
@@ -1304,6 +1455,7 @@ mod tests {
13041455 buy_capacity_debt : Some ( RfqCapacity :: Exact ( U256 :: from ( 1u64 ) ) ) ,
13051456 sell_capacity_collateral : Some ( RfqCapacity :: Exact ( U256 :: from ( 1u64 ) ) ) ,
13061457 feed_url : "http://feed" . into ( ) ,
1458+ staleness_secs : 240 ,
13071459 }
13081460 }
13091461
0 commit comments