@@ -2,8 +2,10 @@ use std::sync::Arc;
22
33use async_trait:: async_trait;
44use axum:: {
5+ body:: Body ,
56 extract:: { Query , State } ,
6- http:: StatusCode ,
7+ http:: { HeaderMap , StatusCode , header} ,
8+ response:: Response ,
79 Json ,
810} ;
911use chrono:: { DateTime , Duration , Utc } ;
@@ -16,6 +18,7 @@ use crate::error::AppError;
1618use crate :: insights:: { FeeDataPoint , FeeInsightsEngine , TrendIndicator , TrendStrength } ;
1719use crate :: services:: horizon:: HorizonClient ;
1820use crate :: store:: FeeHistoryStore ;
21+ use super :: headers:: { cache_control, compute_etag, if_none_match_matches, last_modified} ;
1922
2023/// Shared state type for the fees route.
2124pub type FeesState = Arc < FeesApiState > ;
@@ -73,32 +76,99 @@ pub struct CurrentFeeResponse {
7376 pub percentiles : PercentileFees ,
7477}
7578
79+ const FEES_CURRENT_MAX_AGE : u32 = 5 ;
80+ const FEES_CURRENT_SWR : u32 = 10 ;
81+ const FEES_HISTORY_MAX_AGE : u32 = 30 ;
82+ const FEES_HISTORY_SWR : u32 = 60 ;
83+
84+ async fn resolve_last_modified ( state : & FeesState ) -> axum:: http:: HeaderValue {
85+ let timestamp = match state. insights_engine . as_ref ( ) {
86+ Some ( engine) => engine
87+ . read ( )
88+ . await
89+ . get_last_update ( )
90+ . unwrap_or_else ( Utc :: now) ,
91+ None => Utc :: now ( ) ,
92+ } ;
93+ last_modified ( timestamp)
94+ }
95+
96+ fn not_modified_response (
97+ max_age : u32 ,
98+ swr : u32 ,
99+ etag : & str ,
100+ last_modified_value : axum:: http:: HeaderValue ,
101+ ) -> Response {
102+ Response :: builder ( )
103+ . status ( StatusCode :: NOT_MODIFIED )
104+ . header ( header:: CACHE_CONTROL , cache_control ( max_age, swr) )
105+ . header ( header:: ETAG , etag)
106+ . header ( header:: LAST_MODIFIED , last_modified_value)
107+ . body ( Body :: empty ( ) )
108+ . expect ( "304 response should be valid" )
109+ }
110+
111+ fn json_cache_response (
112+ max_age : u32 ,
113+ swr : u32 ,
114+ etag : & str ,
115+ last_modified_value : axum:: http:: HeaderValue ,
116+ body : Vec < u8 > ,
117+ ) -> Response {
118+ Response :: builder ( )
119+ . status ( StatusCode :: OK )
120+ . header ( header:: CONTENT_TYPE , "application/json" )
121+ . header ( header:: CACHE_CONTROL , cache_control ( max_age, swr) )
122+ . header ( header:: ETAG , etag)
123+ . header ( header:: LAST_MODIFIED , last_modified_value)
124+ . body ( Body :: from ( body) )
125+ . expect ( "cached response should be valid" )
126+ }
127+
76128pub async fn current_fees (
77129 State ( state) : State < FeesState > ,
78- ) -> Result < Json < CurrentFeeResponse > , AppError > {
79- {
130+ request_headers : HeaderMap ,
131+ ) -> Result < Response , AppError > {
132+ let cached = {
80133 let cache = state. fee_cache . lock ( ) . await ;
81134 if cache. is_fresh ( ) {
82- if let Some ( cached ) = cache. get ( ) {
83- return Ok ( Json ( cached ) ) ;
84- }
135+ cache. get ( )
136+ } else {
137+ None
85138 }
86- }
87-
88- let provider = state
89- . fee_stats_provider
90- . as_ref ( )
91- . ok_or_else ( || {
139+ } ;
140+ let payload = if let Some ( cached) = cached {
141+ cached
142+ } else {
143+ let provider = state. fee_stats_provider . as_ref ( ) . ok_or_else ( || {
92144 AppError :: Config ( "Fee stats provider missing from fees state" . to_string ( ) )
93145 } ) ?;
94- let fresh = provider. fetch_current_fees ( ) . await ?;
95-
96- {
146+ let fresh = provider. fetch_current_fees ( ) . await ?;
97147 let mut cache = state. fee_cache . lock ( ) . await ;
98148 cache. set ( fresh. clone ( ) ) ;
149+ fresh
150+ } ;
151+
152+ let body = serde_json:: to_vec ( & payload) . map_err ( |err| AppError :: Parse ( err. to_string ( ) ) ) ?;
153+ let etag = compute_etag ( & body) ;
154+ let last_modified_value = resolve_last_modified ( & state) . await ;
155+
156+ if if_none_match_matches ( & request_headers, & etag) {
157+ return Ok ( not_modified_response (
158+ FEES_CURRENT_MAX_AGE ,
159+ FEES_CURRENT_SWR ,
160+ & etag,
161+ last_modified_value,
162+ ) ) ;
99163 }
100164
101- Ok ( Json ( fresh) )
165+ Ok ( json_cache_response (
166+ FEES_CURRENT_MAX_AGE ,
167+ FEES_CURRENT_SWR ,
168+ & etag,
169+ last_modified_value,
170+ body,
171+ ) )
102172}
103173
104174#[ derive( Debug , Deserialize ) ]
@@ -128,7 +198,8 @@ pub struct FeeHistoryResponse {
128198pub async fn fee_history (
129199 State ( state) : State < FeesState > ,
130200 Query ( params) : Query < FeeHistoryQuery > ,
131- ) -> Result < Json < FeeHistoryResponse > , ( StatusCode , Json < Value > ) > {
201+ request_headers : HeaderMap ,
202+ ) -> Result < Response , ( StatusCode , Json < Value > ) > {
132203 let window = params. window . unwrap_or_else ( || "1h" . to_string ( ) ) ;
133204 let duration = parse_window ( & window) . ok_or_else ( || {
134205 (
@@ -145,14 +216,39 @@ pub async fn fee_history(
145216 } ;
146217 let summary = compute_summary ( & fees) ;
147218
148- Ok ( Json ( FeeHistoryResponse {
219+ let payload = FeeHistoryResponse {
149220 window,
150221 from,
151222 to,
152223 data_points : fees. len ( ) ,
153224 fees,
154225 summary,
155- } ) )
226+ } ;
227+ let body = serde_json:: to_vec ( & payload) . map_err ( |err| {
228+ (
229+ StatusCode :: INTERNAL_SERVER_ERROR ,
230+ Json ( json ! ( { "error" : format!( "Failed to serialize fee history: {}" , err) } ) ) ,
231+ )
232+ } ) ?;
233+ let etag = compute_etag ( & body) ;
234+ let last_modified_value = resolve_last_modified ( & state) . await ;
235+
236+ if if_none_match_matches ( & request_headers, & etag) {
237+ return Ok ( not_modified_response (
238+ FEES_HISTORY_MAX_AGE ,
239+ FEES_HISTORY_SWR ,
240+ & etag,
241+ last_modified_value,
242+ ) ) ;
243+ }
244+
245+ Ok ( json_cache_response (
246+ FEES_HISTORY_MAX_AGE ,
247+ FEES_HISTORY_SWR ,
248+ & etag,
249+ last_modified_value,
250+ body,
251+ ) )
156252}
157253
158254fn parse_window ( value : & str ) -> Option < Duration > {
@@ -525,6 +621,50 @@ mod tests {
525621 assert_eq ! ( mock. calls( ) , 2 , "expired cache should trigger refetch" ) ;
526622 }
527623
624+ #[ tokio:: test]
625+ async fn current_fees_returns_304_when_if_none_match_matches ( ) {
626+ let mock = MockFeeStatsProvider :: new ( vec ! [ make_current_fee_response( "100" ) ] ) ;
627+ let state = make_fee_state_with_provider ( Arc :: new ( mock) , StdDuration :: from_secs ( 60 ) ) ;
628+
629+ let app = Router :: new ( )
630+ . route ( "/fees/current" , get ( current_fees) )
631+ . with_state ( state) ;
632+
633+ let first = app
634+ . clone ( )
635+ . oneshot (
636+ Request :: builder ( )
637+ . uri ( "/fees/current" )
638+ . body ( Body :: empty ( ) )
639+ . unwrap ( ) ,
640+ )
641+ . await
642+ . unwrap ( ) ;
643+ assert_eq ! ( first. status( ) , StatusCode :: OK ) ;
644+ let etag = first
645+ . headers ( )
646+ . get ( "etag" )
647+ . expect ( "missing etag header" )
648+ . to_str ( )
649+ . unwrap ( )
650+ . to_string ( ) ;
651+
652+ let second = app
653+ . oneshot (
654+ Request :: builder ( )
655+ . uri ( "/fees/current" )
656+ . header ( "if-none-match" , etag)
657+ . body ( Body :: empty ( ) )
658+ . unwrap ( ) ,
659+ )
660+ . await
661+ . unwrap ( ) ;
662+
663+ assert_eq ! ( second. status( ) , StatusCode :: NOT_MODIFIED ) ;
664+ let body = to_bytes ( second. into_body ( ) , usize:: MAX ) . await . unwrap ( ) ;
665+ assert ! ( body. is_empty( ) , "304 response should not include body" ) ;
666+ }
667+
528668 #[ tokio:: test]
529669 async fn fee_history_returns_data_points_and_summary_for_supported_windows ( ) {
530670 for window in [ "1h" , "6h" , "24h" ] {
0 commit comments