use super::config::Currency;
use crate::parsers;
use crate::parsers::constants::{CLK_URL, FLIGHTS_MAIN_PAGE};
use crate::requests::config::Config;
use anyhow::Result;
use chrono::{Duration, Months, NaiveDate};
use governor::{DefaultDirectRateLimiter, Quota};
use parsers::calendar_graph_request::GraphRequestOptions;
use parsers::calendar_graph_response::GraphRawResponseContainer;
use parsers::city_request::CityRequestOptions;
use parsers::city_response::ResponseInnerBodyParsed;
use parsers::common::ToRequestBody;
use parsers::date_grid_request::{DateGridRequestOptions, DATE_GRID_MAX_CELLS};
use parsers::date_grid_response::{parse_date_grid_response, DateGridResponse};
use parsers::flight_request::FlightRequestOptions;
use parsers::flight_response::{create_raw_response_vec, FlightResponseContainer};
use parsers::offer_response::{self, OfferRawResponseContainer};
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::{Client, Response, StatusCode};
use std::num::NonZeroU32;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Debug)]
pub struct RateLimitedError;
impl std::fmt::Display for RateLimitedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Google Flights returned HTTP 429 Too Many Requests — all further requests on this client are blocked; call ApiClient::reset_rate_limit() to resume")
}
}
impl std::error::Error for RateLimitedError {}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_attempts: u32,
pub base_delay_ms: u64,
pub cap_delay_ms: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
base_delay_ms: 500,
cap_delay_ms: 30_000,
}
}
}
#[derive(Clone)]
pub struct ApiClient {
pub rate_limiter: Arc<DefaultDirectRateLimiter>,
pub client: Arc<Client>,
frontend_version: String,
rate_limited: Arc<AtomicBool>,
retry_config: RetryConfig,
}
impl ApiClient {
pub async fn new() -> Self {
let rate_limiter_quota = Quota::per_second(NonZeroU32::MIN.saturating_add(9));
Self::new_with_ratelimit(rate_limiter_quota).await
}
pub async fn new_with_ratelimit(rate_limiter_quota: Quota) -> Self {
let rate_limiter: Arc<DefaultDirectRateLimiter> =
Arc::new(DefaultDirectRateLimiter::direct(rate_limiter_quota));
let frontend_version = get_frontend_version().await;
Self {
rate_limiter,
client: Arc::new(Client::new()),
frontend_version: frontend_version
.unwrap_or("boq_travel-frontend-flights-ui_20260527.01_p0".into()),
rate_limited: Arc::new(AtomicBool::new(false)),
retry_config: RetryConfig::default(),
}
}
pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
self.retry_config = retry_config;
self
}
pub fn is_rate_limited(&self) -> bool {
self.rate_limited.load(Ordering::SeqCst)
}
pub fn reset_rate_limit(&self) {
self.rate_limited.store(false, Ordering::SeqCst);
}
#[tracing::instrument(skip(self))]
pub async fn request_city(&self, city: &str) -> Result<ResponseInnerBodyParsed> {
let options = CityRequestOptions {
city: city.to_owned(),
frontend_version: self.frontend_version.clone(),
};
let city_response: &str = &self
.do_request(&options, None, "en", "GB")
.await?
.text()
.await?;
let cities_res = ResponseInnerBodyParsed::try_from(city_response)?;
Ok(cities_res)
}
#[tracing::instrument(skip_all)]
pub async fn request_graph(
&self,
args: &Config,
months: Months,
) -> Result<GraphRawResponseContainer> {
let date_end_graph = args
.get_end_graph(months)
.ok_or_else(|| anyhow::anyhow!("date overflow when computing graph end date"))?
.to_string();
let req_options = GraphRequestOptions {
departing_city: &args.departure,
arriving_city: &args.destination,
date_start: &args.departing_date,
date_return: args.return_date.as_ref(),
date_end_graph: &date_end_graph,
travellers: args.travellers.clone(),
travel_class: &args.travel_class,
stop_option: &args.stop_options,
departing_times: &args.departing_times,
return_times: &args.return_times,
stopover_max: &args.stopover_max,
stopover_min: &args.stopover_min,
duration_max: &args.duration_max,
frontend_version: &self.frontend_version,
language: &args.language,
country: &args.country,
sort_order: &args.sort_order,
};
let body = self
.do_request(
&req_options,
Some(args.currency.clone()),
&args.language,
&args.country,
)
.await?
.text()
.await?;
GraphRawResponseContainer::try_from(body.as_ref())
}
#[tracing::instrument(skip_all)]
pub async fn request_date_grid(
&self,
args: &Config,
dep_start: NaiveDate,
dep_end: NaiveDate,
ret_start: NaiveDate,
ret_end: NaiveDate,
) -> Result<DateGridResponse> {
args.return_date
.ok_or_else(|| anyhow::anyhow!("date grid requires a return date in Config"))?;
let dep_days = (dep_end - dep_start).num_days() + 1;
let ret_days = (ret_end - ret_start).num_days() + 1;
let total_cells = dep_days * ret_days;
if total_cells <= DATE_GRID_MAX_CELLS as i64 {
return self
.request_date_grid_chunk(args, dep_start, dep_end, ret_start, ret_end)
.await;
}
let max_ret_chunk = ((DATE_GRID_MAX_CELLS as i64) / dep_days).max(1);
tracing::info!(
dep_days,
ret_days,
max_ret_chunk,
"date grid too large, splitting into chunks"
);
let mut all_entries = Vec::new();
let mut chunk_ret_start = ret_start;
while chunk_ret_start <= ret_end {
let chunk_ret_end = (chunk_ret_start + Duration::days(max_ret_chunk - 1)).min(ret_end);
let chunk = self
.request_date_grid_chunk(args, dep_start, dep_end, chunk_ret_start, chunk_ret_end)
.await?;
all_entries.extend(chunk.entries);
chunk_ret_start = chunk_ret_end + Duration::days(1);
}
Ok(DateGridResponse {
entries: all_entries,
})
}
async fn request_date_grid_chunk(
&self,
args: &Config,
dep_start: NaiveDate,
dep_end: NaiveDate,
ret_start: NaiveDate,
ret_end: NaiveDate,
) -> Result<DateGridResponse> {
let dep_ref = args.departing_date.max(dep_start).min(dep_end);
let ret_ref = args
.return_date
.unwrap_or(ret_start)
.max(ret_start)
.min(ret_end);
let req_options = DateGridRequestOptions::new(
&args.departure,
&args.destination,
&dep_ref,
&ret_ref,
&dep_start,
&dep_end,
&ret_start,
&ret_end,
args.travellers.clone(),
&args.travel_class,
&args.stop_options,
&args.departing_times,
&args.return_times,
&args.stopover_max,
&args.duration_max,
&self.frontend_version,
);
let body = self
.do_request(
&req_options,
Some(args.currency.clone()),
&args.language,
&args.country,
)
.await?
.text()
.await?;
parse_date_grid_response(&body)
}
#[tracing::instrument(skip_all, fields(
from = ?args.departure.iter().map(|l| l.loc_identifier.as_str()).collect::<Vec<_>>(),
to = ?args.destination.iter().map(|l| l.loc_identifier.as_str()).collect::<Vec<_>>(),
date = %args.departing_date,
class = ?args.travel_class,
stops = ?args.stop_options,
))]
pub async fn request_flights(&self, args: &Config) -> Result<FlightResponseContainer> {
tracing::info!("Requesting flights");
let body = self.fetch_flight_body(args).await?;
create_raw_response_vec(body)
}
#[tracing::instrument(skip_all, fields(
from = ?args.departure.iter().map(|l| l.loc_identifier.as_str()).collect::<Vec<_>>(),
to = ?args.destination.iter().map(|l| l.loc_identifier.as_str()).collect::<Vec<_>>(),
date = %args.departing_date,
class = ?args.travel_class,
stops = ?args.stop_options,
))]
pub async fn request_offer(&self, args: &Config) -> Result<OfferRawResponseContainer> {
tracing::info!("Requesting offers");
let body = self.fetch_flight_body(args).await?;
tracing::trace!(body = %body, "raw offer response body");
offer_response::create_raw_response_offer_vec(body)
}
async fn fetch_flight_body(&self, args: &Config) -> Result<String> {
let date_start = args.departing_date.to_string();
let date_return = args.return_date.map(|f| f.to_string());
let server_sort = args.sort_order.server_sort();
let req_options = FlightRequestOptions {
departing_city: &args.departure,
arriving_city: &args.destination,
date_start: &date_start,
date_return: date_return.as_deref(),
travellers: args.travellers.clone(),
travel_class: &args.travel_class,
stop_option: &args.stop_options,
departing_times: &args.departing_times,
return_times: &args.return_times,
stopover_max: &args.stopover_max,
stopover_min: &args.stopover_min,
duration_max: &args.duration_max,
frontend_version: &self.frontend_version,
fixed_flights: &args.fixed_flights,
language: &args.language,
country: &args.country,
sort_order: &server_sort,
airlines_include: &args.airlines_include,
airlines_exclude: &args.airlines_exclude,
connecting_airports: &args.connecting_airports,
lower_emissions: args.lower_emissions,
};
Ok(self
.do_request(
&req_options,
Some(args.currency.clone()),
&args.language,
&args.country,
)
.await?
.text()
.await?)
}
#[tracing::instrument(skip_all)]
pub async fn resolve_booking_url(&self, click_token: &str) -> Result<String> {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
let url = format!("{CLK_URL}?t={t}");
let body = format!("u={click_token}");
tracing::debug!(%url, "resolving booking URL");
let html = self
.client
.post(&url)
.body(body)
.headers(get_headers(None, "en", "GB")?)
.send()
.await?
.text()
.await?;
let re = Regex::new(r#"(?i)url=['"]([^'"]+)['"]"#).unwrap();
let raw = re
.captures(&html)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.ok_or_else(|| anyhow::anyhow!("no redirect URL found in clk/f response"))?;
Ok(raw.replace("&", "&"))
}
#[tracing::instrument(skip_all)]
async fn do_request(
&self,
options: &impl ToRequestBody,
currency: Option<Currency>,
language: &str,
country: &str,
) -> Result<Response> {
if self.rate_limited.load(Ordering::SeqCst) {
return Err(anyhow::Error::new(RateLimitedError));
}
let req_payload = options.to_request_body()?;
let headers = get_headers(currency, language, country)?;
let decoded_body = percent_encoding::percent_decode_str(&req_payload.body)
.decode_utf8_lossy()
.into_owned();
tracing::trace!(
url = %req_payload.url,
body = %decoded_body,
?headers,
"Outgoing POST request"
);
let max_attempts = self.retry_config.max_attempts.max(1);
let base_delay = self.retry_config.base_delay_ms;
let cap_delay = self.retry_config.cap_delay_ms;
let mut last_err: anyhow::Error = anyhow::anyhow!("all retry attempts exhausted");
for attempt in 0..max_attempts {
if attempt > 0 {
let backoff = (base_delay * (1u64 << (attempt - 1).min(30))).min(cap_delay);
let jitter = (attempt as u64 * 37) % 101;
let delay_ms = backoff + jitter;
tracing::debug!(
attempt,
delay_ms,
"transient error — retrying after back-off"
);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
let _permit = self
.rate_limiter
.until_n_ready(NonZeroU32::MIN) .await;
let res = match self
.client
.post(req_payload.url.as_str())
.body(req_payload.body.clone())
.headers(headers.clone())
.send()
.await
{
Ok(r) => r,
Err(e) if e.is_timeout() => {
tracing::warn!(attempt, error = %e, "request timed out");
last_err = e.into();
continue; }
Err(e) => return Err(e.into()), };
tracing::trace!(
status = %res.status(),
http_version = ?res.version(),
"Response received"
);
match res.status() {
StatusCode::OK => return Ok(res),
StatusCode::TOO_MANY_REQUESTS => {
self.rate_limited.store(true, Ordering::SeqCst);
return Err(anyhow::Error::new(RateLimitedError));
}
StatusCode::INTERNAL_SERVER_ERROR
| StatusCode::BAD_GATEWAY
| StatusCode::SERVICE_UNAVAILABLE
| StatusCode::GATEWAY_TIMEOUT => {
tracing::warn!(
attempt,
status = %res.status(),
"server error — will retry if attempts remain"
);
last_err = anyhow::anyhow!("server error: {}", res.status());
}
status => {
tracing::warn!(
http_version = ?res.version(),
status_code = %status,
"Unexpected HTTP response status"
);
return Ok(res);
}
}
}
Err(last_err)
}
}
fn base_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT_LANGUAGE,
HeaderValue::from_static("en-US,en;q=0.9"),
);
headers.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded;charset=UTF-8"),
);
headers.insert(
reqwest::header::PRAGMA,
HeaderValue::from_static("no-cache"),
);
headers.insert(
reqwest::header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
headers.insert(
reqwest::header::USER_AGENT,
HeaderValue::from_static(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
),
);
headers.insert(reqwest::header::ACCEPT, HeaderValue::from_static("*/*"));
headers
}
fn get_headers(currency: Option<Currency>, language: &str, country: &str) -> Result<HeaderMap> {
let mut headers = base_headers();
if let Some(currency) = currency {
let country_upper = country.to_uppercase();
let currency_header = format!(
r#"["{language}-{country_upper}","{country_upper}","{}",1,null,[-120],null,[[72534415,72446893,97456553,72399613]],1,[]]"#,
currency
);
let header_value = reqwest::header::HeaderValue::from_str(¤cy_header)
.map_err(|e| anyhow::anyhow!("invalid currency header value: {e}"))?;
headers.insert(
reqwest::header::HeaderName::from_static("x-goog-ext-259736195-jspb"),
header_value,
);
}
Ok(headers)
}
async fn get_frontend_version() -> Option<String> {
let client = Client::new();
let headers = base_headers(); let url = FLIGHTS_MAIN_PAGE.to_string();
let res = client.get(&url).headers(headers).send().await.ok()?;
let status = res.status();
let final_url = res.url().to_string();
fn base_url(u: &str) -> &str {
u.split_once('?').map_or(u, |(base, _)| base)
}
if base_url(&final_url) != base_url(&url) {
tracing::warn!(
original_url = %url,
final_url = %final_url,
status = %status,
"main page request was redirected to a different URL"
);
} else {
tracing::debug!(url = %final_url, status = %status, "main page response");
}
let response_body = res.text().await.ok()?;
let regex = match Regex::new(
r"(boq_travel-frontend-[\w-]*ui_202[456789](01|02|03|04|05|06|07|08|09|10|11|12)\d{2}.\w{5,})",
) {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "failed to compile version regex; using fallback version");
return None;
}
};
let result = regex
.captures_iter(&response_body)
.map(|f| f.extract::<2>())
.next();
match &result {
Some((version, _)) => tracing::debug!(version, "frontend version extracted"),
None => tracing::warn!(
response_len = response_body.len(),
"frontend version not found in main page response; using hardcoded fallback"
),
}
Some(result?.0.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_client() -> ApiClient {
let quota = governor::Quota::per_second(NonZeroU32::new(100).unwrap());
ApiClient {
rate_limiter: Arc::new(DefaultDirectRateLimiter::direct(quota)),
client: Arc::new(Client::new()),
frontend_version: "test".into(),
rate_limited: Arc::new(AtomicBool::new(false)),
retry_config: RetryConfig::default(),
}
}
#[test]
fn not_rate_limited_by_default() {
let client = make_client();
assert!(!client.is_rate_limited());
}
#[test]
fn rate_limited_flag_can_be_set_and_reset() {
let client = make_client();
client.rate_limited.store(true, Ordering::SeqCst);
assert!(client.is_rate_limited());
client.reset_rate_limit();
assert!(!client.is_rate_limited());
}
#[test]
fn clones_share_the_rate_limited_flag() {
let client = make_client();
let clone = client.clone();
client.rate_limited.store(true, Ordering::SeqCst);
assert!(clone.is_rate_limited());
clone.reset_rate_limit();
assert!(!client.is_rate_limited());
}
#[test]
fn rate_limited_error_is_downcasted() {
let err = anyhow::Error::new(RateLimitedError);
assert!(err.downcast_ref::<RateLimitedError>().is_some());
}
#[test]
fn retry_config_default_values() {
let cfg = RetryConfig::default();
assert_eq!(cfg.max_attempts, 3);
assert_eq!(cfg.base_delay_ms, 500);
assert_eq!(cfg.cap_delay_ms, 30_000);
}
#[test]
fn with_retry_config_overrides_defaults() {
let client = make_client();
let custom = RetryConfig {
max_attempts: 5,
base_delay_ms: 200,
cap_delay_ms: 10_000,
};
let client = client.with_retry_config(custom.clone());
assert_eq!(client.retry_config.max_attempts, 5);
assert_eq!(client.retry_config.base_delay_ms, 200);
assert_eq!(client.retry_config.cap_delay_ms, 10_000);
}
#[test]
fn retry_config_max_attempts_one_means_no_retries() {
let cfg = RetryConfig {
max_attempts: 1,
base_delay_ms: 500,
cap_delay_ms: 30_000,
};
assert_eq!(cfg.max_attempts.max(1), 1);
}
}