use super::config::Currency;
use crate::parsers;
use crate::parsers::common::FixedFlights;
use crate::parsers::constants::{CLK_URL, FLIGHTS_MAIN_PAGE};
use crate::requests::config::deals::{DealConfig, DealResult};
use crate::requests::config::explore::ExploreResult;
use crate::requests::config::{Config, ExploreConfig, MultiCityConfig, TripType};
use anyhow::Result;
use chrono::{Duration, Months, NaiveDate};
use futures::StreamExt as _;
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, CheapDate, DateGridResponse};
use parsers::deals_request::DealsRequestOptions;
use parsers::deals_response::parse_deals_response;
use parsers::explore_request::ExploreRequestOptions;
use parsers::explore_response::parse_explore_response;
use parsers::flight_request::{FlightRequestOptions, MultiCityRequestOptions};
use parsers::flight_response::{create_raw_response_vec, FlightResponseContainer};
use parsers::offer_response::{self, OfferRawResponseContainer};
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::{Client, Response, StatusCode};
use std::num::NonZeroU32;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
#[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,
f_sid: String,
rate_limited: Arc<AtomicBool>,
retry_config: RetryConfig,
user_agent: String,
currency: Currency,
language: String,
country: String,
session_cookies: Option<Arc<str>>,
}
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 {
Self::build(rate_limiter_quota, None)
.await
.expect("building a proxy-less HTTP client never fails")
}
pub async fn new_with_proxy(proxy: impl Into<String>) -> Result<Self> {
let rate_limiter_quota = Quota::per_second(NonZeroU32::MIN.saturating_add(9));
Self::build(rate_limiter_quota, Some(proxy.into())).await
}
async fn build(rate_limiter_quota: Quota, proxy: Option<String>) -> Result<Self> {
let rate_limiter: Arc<DefaultDirectRateLimiter> =
Arc::new(DefaultDirectRateLimiter::direct(rate_limiter_quota));
let user_agent = pick_user_agent().to_string();
tracing::debug!(%user_agent, proxy = ?proxy, "constructing client");
let client = build_reqwest_client(proxy.as_deref())?;
let page = fetch_main_page(&user_agent, &client).await;
let frontend_version = page.as_ref().and_then(|(h, _)| extract_frontend_version(h));
let f_sid = page.as_ref().and_then(|(h, _)| extract_f_sid(h));
let session_cookies = match page {
Some((_, c)) => (!c.is_empty()).then(|| Arc::from(c)),
None => None,
};
Ok(Self {
rate_limiter,
client: Arc::new(client),
frontend_version: frontend_version
.unwrap_or("boq_travel-frontend-flights-ui_20260527.01_p0".into()),
f_sid: f_sid.unwrap_or_else(|| "-1".into()),
rate_limited: Arc::new(AtomicBool::new(false)),
retry_config: RetryConfig::default(),
user_agent,
currency: Currency::default(),
language: "en".to_string(),
country: "GB".to_string(),
session_cookies,
})
}
pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
self.retry_config = retry_config;
self
}
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn user_agent(&self) -> &str {
&self.user_agent
}
pub fn with_currency(mut self, currency: Currency) -> Self {
self.currency = currency;
self
}
pub fn with_language(mut self, language: impl Into<String>) -> Self {
self.language = language.into();
self
}
pub fn with_country(mut self, country: impl Into<String>) -> Self {
self.country = country.into();
self
}
pub fn with_locale(
mut self,
currency: Currency,
language: impl Into<String>,
country: impl Into<String>,
) -> Self {
self.currency = currency;
self.language = language.into();
self.country = country.into();
self
}
pub fn currency(&self) -> &Currency {
&self.currency
}
pub fn language(&self) -> &str {
&self.language
}
pub fn country(&self) -> &str {
&self.country
}
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, &self.language, &self.country)
.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: &self.language,
country: &self.country,
sort_order: &args.sort_order,
};
let body = self
.do_request(
&req_options,
Some(self.currency.clone()),
&self.language,
&self.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 chunk_dim = (DATE_GRID_MAX_CELLS as f64).sqrt() as i64;
let mut chunks: Vec<(NaiveDate, NaiveDate, NaiveDate, NaiveDate)> = Vec::new();
let mut chunk_dep_start = dep_start;
while chunk_dep_start <= dep_end {
let chunk_dep_end = (chunk_dep_start + Duration::days(chunk_dim - 1)).min(dep_end);
let chunk_dep_days = (chunk_dep_end - chunk_dep_start).num_days() + 1;
let max_ret_chunk = ((DATE_GRID_MAX_CELLS as i64) / chunk_dep_days).max(1);
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);
chunks.push((
chunk_dep_start,
chunk_dep_end,
chunk_ret_start,
chunk_ret_end,
));
chunk_ret_start = chunk_ret_end + Duration::days(1);
}
chunk_dep_start = chunk_dep_end + Duration::days(1);
}
tracing::info!(
dep_days,
ret_days,
chunk_dim,
chunk_count = chunks.len(),
"date grid too large, splitting into parallel chunks"
);
const MAX_CONCURRENT: usize = 8;
let results: Vec<Result<DateGridResponse>> = futures::stream::iter(chunks)
.map(|(dep_s, dep_e, ret_s, ret_e)| async move {
self.request_date_grid_chunk(args, dep_s, dep_e, ret_s, ret_e)
.await
})
.buffer_unordered(MAX_CONCURRENT)
.collect()
.await;
let mut all_entries = Vec::new();
for result in results {
all_entries.extend(result?.entries);
}
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 max_attempts = self.retry_config.max_attempts.max(1);
let mut last_err: anyhow::Error = anyhow::anyhow!("all body-read attempts exhausted");
for attempt in 0..max_attempts {
if attempt > 0 {
let delay_ms = (self.retry_config.base_delay_ms * (1u64 << (attempt - 1).min(30)))
.min(self.retry_config.cap_delay_ms);
tracing::debug!(attempt, delay_ms, "body read error — retrying chunk");
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
let res = self
.do_request(
&req_options,
Some(self.currency.clone()),
&self.language,
&self.country,
)
.await?;
match res.text().await {
Ok(body) => return parse_date_grid_response(&body),
Err(e) => {
tracing::warn!(attempt, error = %e, "body read failed for date-grid chunk");
last_err = e.into();
}
}
}
Err(last_err)
}
#[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: &self.language,
country: &self.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,
max_price: args.max_price,
baggage: args.baggage,
};
Ok(self
.do_request(
&req_options,
Some(self.currency.clone()),
&self.language,
&self.country,
)
.await?
.text()
.await?)
}
#[tracing::instrument(skip_all, fields(
legs = args.legs.len(),
class = ?args.travel_class,
))]
pub async fn request_multi_city_flights(
&self,
args: &MultiCityConfig,
) -> Result<FlightResponseContainer> {
tracing::info!("Requesting multi-city flights");
let req_options = MultiCityRequestOptions {
config: args,
frontend_version: &self.frontend_version,
language: &self.language,
country: &self.country,
};
let body = self
.do_request(
&req_options,
Some(self.currency.clone()),
&self.language,
&self.country,
)
.await?
.text()
.await?;
create_raw_response_vec(body)
}
#[tracing::instrument(skip_all, fields(
origin = ?config.origin.iter().map(|l| l.loc_identifier.as_str()).collect::<Vec<_>>(),
duration = ?config.trip_duration,
))]
pub async fn request_explore(&self, config: &ExploreConfig) -> Result<Vec<ExploreResult>> {
tracing::info!("Requesting explore destinations");
let req_options = ExploreRequestOptions {
config,
frontend_version: &self.frontend_version,
language: &self.language,
country: &self.country,
};
let body = self
.do_request(
&req_options,
Some(self.currency.clone()),
&self.language,
&self.country,
)
.await?
.text()
.await?;
parse_explore_response(&body)
}
#[tracing::instrument(skip_all, fields(
origin = ?config.origin.iter().map(|l| l.loc_identifier.as_str()).collect::<Vec<_>>(),
))]
pub async fn request_deals(&self, config: &DealConfig) -> Result<Vec<DealResult>> {
tracing::info!("Requesting flight deals");
let req_options = DealsRequestOptions {
config,
frontend_version: &self.frontend_version,
language: &self.language,
country: &self.country,
};
let body = self
.do_request(
&req_options,
Some(self.currency.clone()),
&self.language,
&self.country,
)
.await?
.text()
.await?;
parse_deals_response(&body)
}
#[tracing::instrument(skip_all)]
pub async fn cheapest_dates(
&self,
config: &Config,
months: Months,
trip_duration_days: Option<u32>,
) -> Result<Vec<CheapDate>> {
match trip_duration_days {
None => {
let graph = self.request_graph(config, months).await?;
let mut results: Vec<CheapDate> = graph
.get_all_graphs()
.into_iter()
.filter_map(|e| {
e.proposed_trip_cost.as_ref().map(|c| CheapDate {
departure_date: e.proposed_departure_date,
return_date: e.proposed_return_date,
price: c.trip_cost.price,
})
})
.collect();
results.sort_by_key(|e| e.price);
Ok(results)
}
Some(n) => {
let dep_start = config.departing_date;
let n_duration = Duration::days(i64::from(n));
let dep_end = dep_start + months;
let ret_start = dep_start + n_duration;
let ret_end = dep_end + n_duration;
let rt_config = Config {
return_date: Some(dep_start + n_duration),
trip_type: TripType::Return,
fixed_flights: FixedFlights::new(2),
..config.clone()
};
let grid = self
.request_date_grid(&rt_config, dep_start, dep_end, ret_start, ret_end)
.await?;
let mut results: Vec<CheapDate> = grid
.entries
.into_iter()
.filter(|e| e.return_date - e.departure_date == n_duration)
.map(|e| CheapDate {
departure_date: e.departure_date,
return_date: Some(e.return_date),
price: e.price,
})
.collect();
results.sort_by_key(|e| e.price);
Ok(results)
}
}
}
#[tracing::instrument(skip_all)]
pub async fn resolve_booking_url(&self, click_token: &str) -> Result<String> {
use std::time::{SystemTime, UNIX_EPOCH};
if self.rate_limited.load(Ordering::SeqCst) {
return Err(anyhow::Error::new(RateLimitedError));
}
let _permit = self.rate_limiter.until_n_ready(NonZeroU32::MIN).await;
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", &self.user_agent)?)
.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 mut req_payload = options.to_request_body()?;
req_payload.url = replace_f_sid(&req_payload.url, &self.f_sid);
tracing::debug!(user_agent = %self.user_agent, "outgoing request User-Agent");
let mut headers = get_headers(currency, language, country, &self.user_agent)?;
if let Some(cookies) = self.session_cookies.as_deref() {
if let Ok(hv) = HeaderValue::from_str(cookies) {
headers.insert(reqwest::header::COOKIE, hv);
}
}
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)
}
}
const DEFAULT_USER_AGENT: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0";
const USER_AGENTS: &[&str] = &[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:126.0) Gecko/20100101 Firefox/126.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
];
fn pick_user_agent() -> &'static str {
let idx = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as usize)
.unwrap_or(0)
% USER_AGENTS.len();
USER_AGENTS[idx]
}
fn base_headers(user_agent: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT_LANGUAGE,
HeaderValue::from_static("en-US,en;q=0.9"),
);
headers.insert(
HeaderName::from_static("x-same-domain"),
HeaderValue::from_static("1"),
);
headers.insert(
reqwest::header::ORIGIN,
HeaderValue::from_static("https://www.google.com"),
);
headers.insert(
reqwest::header::REFERER,
HeaderValue::from_static("https://www.google.com/travel/flights"),
);
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_str(user_agent)
.unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_USER_AGENT)),
);
headers.insert(reqwest::header::ACCEPT, HeaderValue::from_static("*/*"));
headers
}
fn get_headers(
currency: Option<Currency>,
language: &str,
country: &str,
user_agent: &str,
) -> Result<HeaderMap> {
let mut headers = base_headers(user_agent);
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,null,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)
}
fn build_reqwest_client(proxy: Option<&str>) -> Result<Client> {
let mut builder = Client::builder();
if let Some(url) = proxy {
builder = builder.proxy(
reqwest::Proxy::all(url).map_err(|e| anyhow::anyhow!("invalid proxy {url:?}: {e}"))?,
);
}
builder
.build()
.map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))
}
async fn fetch_main_page(user_agent: &str, client: &Client) -> Option<(String, String)> {
let headers = base_headers(user_agent); 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();
let cookie_header = res
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.filter_map(|c| c.split(';').next()) .collect::<Vec<_>>()
.join("; ");
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 html = res.text().await.ok()?;
Some((html, cookie_header))
}
fn extract_frontend_version(response_body: &str) -> Option<String> {
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())
}
fn extract_f_sid(html: &str) -> Option<String> {
let regex = Regex::new(r#"FdrFJe["\]:,]+(-?\d{6,})"#).ok()?;
let sid = regex.captures(html)?.get(1)?.as_str().to_string();
tracing::debug!(f_sid = %sid, "f.sid extracted");
Some(sid)
}
fn replace_f_sid(url: &str, f_sid: &str) -> String {
let Some(start) = url.find("f.sid=") else {
return url.to_string();
};
let value_start = start + "f.sid=".len();
let value_end = url[value_start..]
.find('&')
.map(|i| value_start + i)
.unwrap_or(url.len());
format!("{}{}{}", &url[..value_start], f_sid, &url[value_end..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_f_sid_reads_fdrfje() {
let html = r#"...,"FdrFJe":"-5078133052890781250",..."#;
assert_eq!(extract_f_sid(html).as_deref(), Some("-5078133052890781250"));
}
#[test]
fn replace_f_sid_rewrites_middle_param() {
let url = "https://x/y?f.sid=123&bl=v&hl=en-GB";
assert_eq!(
replace_f_sid(url, "-99"),
"https://x/y?f.sid=-99&bl=v&hl=en-GB"
);
}
#[test]
fn replace_f_sid_rewrites_trailing_param() {
assert_eq!(
replace_f_sid("https://x/y?a=1&f.sid=123", "-99"),
"https://x/y?a=1&f.sid=-99"
);
}
#[test]
fn replace_f_sid_noop_without_param() {
assert_eq!(replace_f_sid("https://x/y?a=1", "-99"), "https://x/y?a=1");
}
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(),
f_sid: "-1".into(),
rate_limited: Arc::new(AtomicBool::new(false)),
retry_config: RetryConfig::default(),
user_agent: pick_user_agent().to_string(),
currency: Currency::default(),
language: "en".to_string(),
country: "GB".to_string(),
session_cookies: None,
}
}
#[test]
fn not_rate_limited_by_default() {
let client = make_client();
assert!(!client.is_rate_limited());
}
#[test]
fn pick_user_agent_is_from_pool() {
assert!(USER_AGENTS.contains(&pick_user_agent()));
}
#[test]
fn user_agent_pool_is_nonempty_and_valid_headers() {
assert!(!USER_AGENTS.is_empty());
for ua in USER_AGENTS {
assert!(
HeaderValue::from_str(ua).is_ok(),
"User-Agent is not a valid header value: {ua}"
);
}
}
#[test]
fn with_user_agent_overrides_and_getter_reflects_it() {
let client = make_client().with_user_agent("Custom-UA/1.0");
assert_eq!(client.user_agent(), "Custom-UA/1.0");
}
#[test]
fn default_client_user_agent_is_from_pool() {
let client = make_client();
assert!(USER_AGENTS.contains(&client.user_agent()));
}
#[test]
fn build_client_accepts_valid_proxies_and_none() {
assert!(build_reqwest_client(None).is_ok());
assert!(build_reqwest_client(Some("http://127.0.0.1:3128")).is_ok());
assert!(build_reqwest_client(Some("https://proxy.example.com:8443")).is_ok());
assert!(build_reqwest_client(Some("socks5://127.0.0.1:9050")).is_ok());
}
#[test]
fn build_client_rejects_invalid_proxy() {
let err = build_reqwest_client(Some("http://has a space/")).unwrap_err();
assert!(
err.to_string().contains("invalid proxy"),
"unexpected error: {err}"
);
}
#[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);
}
#[test]
fn booking_url_regex_extracts_single_quoted_url() {
let html =
r#"<meta content="0;url='https://example.com/book?foo=bar'" http-equiv="refresh">"#;
let re = Regex::new(r#"(?i)url=['"]([^'"]+)['"]"#).unwrap();
let extracted = re
.captures(html)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string());
assert_eq!(
extracted,
Some("https://example.com/book?foo=bar".to_string())
);
}
#[test]
fn booking_url_regex_extracts_double_quoted_url() {
let _html = r#"<meta content="0;url="https://airline.com/booking?ref=123&src=gf"" http-equiv="refresh">"#;
let html2 =
r#"<meta content='0;url="https://airline.com/booking?ref=123"' http-equiv="refresh">"#;
let re = Regex::new(r#"(?i)url=['"]([^'"]+)['"]"#).unwrap();
let extracted = re
.captures(html2)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string());
assert_eq!(
extracted,
Some("https://airline.com/booking?ref=123".to_string())
);
}
#[test]
fn booking_url_amp_entity_is_decoded() {
let raw = "https://example.com/book?foo=1&bar=2";
let decoded = raw.replace("&", "&");
assert_eq!(decoded, "https://example.com/book?foo=1&bar=2");
}
#[test]
fn booking_url_regex_returns_none_on_missing_url() {
let html = "<html><head><title>Error</title></head><body>Not found</body></html>";
let re = Regex::new(r#"(?i)url=['"]([^'"]+)['"]"#).unwrap();
let extracted = re
.captures(html)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string());
assert_eq!(extracted, None);
}
#[test]
fn date_grid_chunk_dim_fits_within_limit() {
use parsers::date_grid_request::DATE_GRID_MAX_CELLS;
let chunk_dim = (DATE_GRID_MAX_CELLS as f64).sqrt() as i64;
let cells = chunk_dim * chunk_dim;
assert!(
cells <= DATE_GRID_MAX_CELLS as i64,
"chunk_dim={chunk_dim} → {cells} cells exceeds limit {DATE_GRID_MAX_CELLS}"
);
}
#[test]
fn date_grid_chunk_count_three_month_window() {
use chrono::NaiveDate;
use parsers::date_grid_request::DATE_GRID_MAX_CELLS;
let chunk_dim = (DATE_GRID_MAX_CELLS as f64).sqrt() as i64; let dep_start = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
let dep_end = dep_start + chrono::Months::new(3);
let ret_start = dep_start + Duration::days(7);
let ret_end = dep_end + Duration::days(7);
let mut count = 0usize;
let mut d = dep_start;
while d <= dep_end {
let de = (d + Duration::days(chunk_dim - 1)).min(dep_end);
let dd = (de - d).num_days() + 1;
let max_ret = ((DATE_GRID_MAX_CELLS as i64) / dd).max(1);
let mut r = ret_start;
while r <= ret_end {
let re = (r + Duration::days(max_ret - 1)).min(ret_end);
count += 1;
r = re + Duration::days(1);
}
d = de + Duration::days(1);
}
assert_eq!(
count, 46,
"expected 46 chunks for a 3-month round-trip scan (Sep-Nov)"
);
}
}