Skip to content

Search API Reference

The main search functionality for finding specific flights.

SearchFlights

fli.search.flights.SearchFlights()

Flight search via Google Flights' FlightsFrontendService API.

Public surface:

  • :meth:search — issue a GetShoppingResults call and return the parsed flights.
  • :meth:get_booking_options — follow up with GetBookingResults to surface bookable fares for a selected itinerary. See the method docstring for the live-token limitation.
Concurrency

SearchFlights is not thread-safe. The instance caches the shopping-session id from the most recent :meth:search call so :meth:get_booking_options can derive the booking token; two concurrent search calls on the same instance will race on that cache and may cross-pollinate sessions between unrelated bookings. :attr:sparse_passenger_mix has the same limitation — it reflects only the most recently completed :meth:search call.

For multi-threaded or async server use, either (a) instantiate a fresh SearchFlights per request, or (b) pass the session_id returned by your own session bookkeeping into :meth:get_booking_options explicitly (the kwarg overrides the cached value).

Initialize the search client.

Source code in fli/search/flights.py
def __init__(self):
    """Initialize the search client."""
    self.client = get_client()
    # Last successful search response's ``inner[0][4]`` — the shopping
    # session id used to authenticate the follow-up GetBookingResults
    # call. Captured automatically by :meth:`search` so that
    # :meth:`get_booking_options` can derive the booking token without
    # the caller having to pass anything.
    self._last_session_id: str | None = None
    # Set at the start of every search() call — see the
    # sparse_passenger_mix property.
    self._sparse_passenger_mix: bool = False

BASE_URL = 'https://www.google.com/_/FlightsFrontendUi/data/travel.frontend.flights.FlightsFrontendService/GetShoppingResults' class-attribute instance-attribute

BOOKING_URL = 'https://www.google.com/_/FlightsFrontendUi/data/travel.frontend.flights.FlightsFrontendService/GetBookingResults' class-attribute instance-attribute

DEFAULT_HEADERS = {'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'} class-attribute instance-attribute

client = get_client() instance-attribute

sparse_passenger_mix: bool property

Whether the sparse-passenger-mix warning fired on the most recent search().

True exactly when the empty result this instance last returned (or raised out of) was consistent with Google's client-side pricing gap for children/infants — at least one fetched page decoded to zero rows before client-side filtering, and the party had a child or infant — rather than the caller's own airline/price/duration/window filter removing rows Google did inline. See SPARSE_PASSENGER_MIX_WARNING. Reset to False at the start of every :meth:search call, including ones that raise, so a stale True from an earlier call never leaks into a later one.

Reflects only the last completed :meth:search call on this instance and is not meant for instances shared across concurrent searches — two overlapping :meth:search calls on one SearchFlights would race on this attribute the same way they already race on _last_session_id (see the class docstring's Concurrency section). The MCP server and CLI each construct a fresh SearchFlights per request, so this is safe there; both read this attribute after :meth:search instead of recomputing the condition themselves.

build_flight_booking_url(flight: FlightResult | tuple[FlightResult, ...], *, currency: str | None = None, language: str | None = None, country: str | None = None, seat_type: SeatType = SeatType.ECONOMY, passenger_info: PassengerInfo | None = None) -> str

Build a Google Flights deep-link URL for a specific itinerary.

Constructs https://www.google.com/travel/flights/booking?tfs=… that opens the booking page pre-loaded with the given itinerary — the airline/OTA fare options and the "Continue" booking CTA included.

The tfs itinerary token is fully deterministic (built from the flight's airports, dates and flight numbers); no session id or network round-trip is required, so the same itinerary always yields the same URL. This method never raises — on malformed input it falls back to the generic Google Flights URL.

PARAMETER DESCRIPTION
flight

A :class:~fli.models.FlightResult (one-way / single segment) or a tuple of them (round-trip / multi-city, one element per travel direction).

TYPE: FlightResult | tuple[FlightResult, ...]

currency

ISO 4217 currency code appended as curr=.

TYPE: str | None DEFAULT: None

language

BCP-47 language code appended as hl=.

TYPE: str | None DEFAULT: None

country

ISO 3166-1 alpha-2 country code appended as gl=.

TYPE: str | None DEFAULT: None

seat_type

Cabin class encoded into the tfs token (field 9). Defaults to economy for backward compatibility.

TYPE: SeatType DEFAULT: ECONOMY

passenger_info

Passenger mix encoded into the tfs token (field 8, one entry per traveller). None defaults to a single adult, matching this method's output before this parameter existed.

TYPE: PassengerInfo | None DEFAULT: None

RETURNS DESCRIPTION
str

A https://www.google.com/travel/flights/booking?tfs=… URL.

Source code in fli/search/flights.py
def build_flight_booking_url(
    self,
    flight: FlightResult | tuple[FlightResult, ...],
    *,
    currency: str | None = None,
    language: str | None = None,
    country: str | None = None,
    seat_type: SeatType = SeatType.ECONOMY,
    passenger_info: PassengerInfo | None = None,
) -> str:
    """Build a Google Flights deep-link URL for a specific itinerary.

    Constructs ``https://www.google.com/travel/flights/booking?tfs=…`` that
    opens the booking page pre-loaded with the given itinerary — the
    airline/OTA fare options and the "Continue" booking CTA included.

    The ``tfs`` itinerary token is fully deterministic (built from the
    flight's airports, dates and flight numbers); no session id or network
    round-trip is required, so the same itinerary always yields the same
    URL. This method never raises — on malformed input it falls back to the
    generic Google Flights URL.

    Args:
        flight: A :class:`~fli.models.FlightResult` (one-way / single
            segment) or a tuple of them (round-trip / multi-city, one
            element per travel direction).
        currency: ISO 4217 currency code appended as ``curr=``.
        language: BCP-47 language code appended as ``hl=``.
        country: ISO 3166-1 alpha-2 country code appended as ``gl=``.
        seat_type: Cabin class encoded into the ``tfs`` token (field 9).
            Defaults to economy for backward compatibility.
        passenger_info: Passenger mix encoded into the ``tfs`` token
            (field 8, one entry per traveller). ``None`` defaults to a
            single adult, matching this method's output before this
            parameter existed.

    Returns:
        A ``https://www.google.com/travel/flights/booking?tfs=…`` URL.

    """
    from fli.search._proto import LegSpec, build_tfs_token, passenger_codes

    def _iata(airport: object) -> str:
        # Handle both Airport enum (has .name) and plain strings.
        return getattr(airport, "name", str(airport)).lstrip("_")

    results: list[FlightResult] = list(flight) if isinstance(flight, tuple) else [flight]
    is_one_way = len(results) == 1

    try:
        segments: list[list[LegSpec]] = []
        for result in results:
            seg_legs = [
                LegSpec(
                    origin=_iata(leg.departure_airport),
                    dep_date=leg.departure_datetime.date().isoformat(),
                    dest=_iata(leg.arrival_airport),
                    airline=_iata(leg.airline),
                    flight_number=leg.flight_number,
                )
                for leg in result.legs
            ]
            segments.append(seg_legs)
        tfs = build_tfs_token(
            segments,
            is_one_way=is_one_way,
            passengers=passenger_codes(passenger_info),
            seat=seat_type.value,
        )
        url = f"https://www.google.com/travel/flights/booking?tfs={tfs}"
    except Exception:
        logger.debug("build_flight_booking_url: tfs construction failed", exc_info=True)
        url = "https://www.google.com/travel/flights"

    return with_locale_params(url, currency, language, country)

get_booking_options(flight: FlightResult | tuple[FlightResult, ...], filters: FlightSearchFilters, currency: str | None = None, language: str | None = None, country: str | None = None, booking_token: str | None = None, session_id: str | None = None) -> list[BookingOption]

Fetch bookable fare options for a selected itinerary.

After a :meth:search call, the session id from Google's response is cached on the client and used here automatically — no explicit token plumbing is required by callers. The booking-call payload carries the same selected_flight legs the caller used in their round-trip search and a protobuf token constructed from the cached session id + the chosen itinerary's identifiers.

PARAMETER DESCRIPTION
flight

A :class:FlightResult (one-way) or tuple of results (round-trip / multi-city) from :meth:search.

TYPE: FlightResult | tuple[FlightResult, ...]

filters

The same filters used in the preceding :meth:search call. A copy is made internally; caller filters are not mutated.

TYPE: FlightSearchFilters

currency

Optional ISO 4217 currency code passed to Google as curr=. Also forms part of the booking token.

TYPE: str | None DEFAULT: None

language

Optional BCP-47 language code (hl URL param).

TYPE: str | None DEFAULT: None

country

Optional ISO 3166-1 alpha-2 country code (gl URL param).

TYPE: str | None DEFAULT: None

booking_token

Explicit override for outer[0][1]. Bypasses the automatic construction; use this when you have a token captured from a browser's tfu URL.

TYPE: str | None DEFAULT: None

session_id

Explicit override for the session id used to build the token. Defaults to the session captured by the most recent :meth:search call on this client.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
list[BookingOption]

A list of :class:BookingOption. Empty list when Google

list[BookingOption]

returns no vendors.

RAISES DESCRIPTION
ValueError

No session id available — either pass it explicitly or call :meth:search first.

Exception

HTTP request failure.

Source code in fli/search/flights.py
def get_booking_options(
    self,
    flight: FlightResult | tuple[FlightResult, ...],
    filters: FlightSearchFilters,
    currency: str | None = None,
    language: str | None = None,
    country: str | None = None,
    booking_token: str | None = None,
    session_id: str | None = None,
) -> list[BookingOption]:
    """Fetch bookable fare options for a selected itinerary.

    After a :meth:`search` call, the session id from Google's response
    is cached on the client and used here automatically — no explicit
    token plumbing is required by callers. The booking-call payload
    carries the same selected_flight legs the caller used in their
    round-trip search and a protobuf token constructed from the
    cached session id + the chosen itinerary's identifiers.

    Args:
        flight: A :class:`FlightResult` (one-way) or tuple of results
            (round-trip / multi-city) from :meth:`search`.
        filters: The same filters used in the preceding :meth:`search`
            call. A copy is made internally; caller filters are not
            mutated.
        currency: Optional ISO 4217 currency code passed to Google as
            ``curr=``. Also forms part of the booking token.
        language: Optional BCP-47 language code (``hl`` URL param).
        country: Optional ISO 3166-1 alpha-2 country code (``gl`` URL param).
        booking_token: Explicit override for ``outer[0][1]``.
            Bypasses the automatic construction; use this when you
            have a token captured from a browser's ``tfu`` URL.
        session_id: Explicit override for the session id used to build
            the token. Defaults to the session captured by the most
            recent :meth:`search` call on this client.

    Returns:
        A list of :class:`BookingOption`. Empty list when Google
        returns no vendors.

    Raises:
        ValueError: No session id available — either pass it
            explicitly or call :meth:`search` first.
        Exception: HTTP request failure.

    """
    results: list[FlightResult] = list(flight) if isinstance(flight, tuple) else [flight]
    if not results:
        raise ValueError("flight argument must be a FlightResult or non-empty tuple of them")

    # Resolve the session id: explicit > cached from prior search.
    effective_session = session_id or self._last_session_id

    token = booking_token
    if token is None and effective_session and results[-1].price is not None:
        # Build a session-anchored token from price + flight info.
        # Skipped when the last result has no shopping-list price
        # (premium-cabin round-trips often hit this) — the per-row
        # token from ``row[8]`` is the correct fallback there.
        from fli.search._proto import build_booking_token

        last = results[-1]
        last_leg = last.legs[-1]
        token = build_booking_token(
            session_id=effective_session,
            airline_code=last_leg.airline.name.removeprefix("_"),
            flight_number=last_leg.flight_number,
            leg_index=1,
            price_cents=int(last.price * 100),
            currency=last.currency or currency or "USD",
        )

    if token is None:
        # Fall back to the per-row token captured at parse time.
        #
        # Prefer the last result's token over the first because:
        #  - For one-way / single-segment trips they are the same row.
        #  - For round-trip / multi-city, ``row[8]`` on each result
        #    encodes the *full* itinerary at parse time (every leg,
        #    every flight number), so any row's token is sufficient
        #    to identify the booking — but using the last leg's
        #    matches Google's own indexing (``build_booking_token``
        #    above uses ``leg_index=1`` for the return leg) and is
        #    the row that ``get_booking_options`` is most likely to
        #    have just parsed if the caller is iterating return-leg
        #    candidates.
        #
        # Accessing the attribute directly fails loudly if the
        # caller passes a non-FlightResult, which is what we want.
        token = results[-1].booking_token or results[0].booking_token
    if not token:
        raise ValueError(
            "Missing booking token. Call SearchFlights.search(...) before "
            "get_booking_options(...) so the client can cache the session "
            "id, or pass `session_id` / `booking_token` explicitly. If "
            "your selected flight has ``price=None`` (premium-cabin "
            "round-trip rows often do — see issue #165), make sure its "
            "``booking_token`` attribute is set; the parser populates "
            "it from ``row[8]`` automatically."
        )

    prepared = deepcopy(filters)
    segments = prepared.flight_segments
    if len(results) > len(segments):
        raise ValueError(f"flight has {len(results)} segments but filters has {len(segments)}")
    for seg, res in zip(segments, results, strict=False):
        seg.selected_flight = res

    encoded = self._encode_booking_payload(token, prepared)
    url = with_locale_params(self.BOOKING_URL, currency, language, country)
    response = self.client.post(
        url=url,
        data=f"f.req={encoded}",
        impersonate="chrome",
        allow_redirects=True,
    )
    response.raise_for_status()

    # Booking responses are typically split into two wrb.fr chunks
    # (vendor list + price refinements). Materialise both before
    # parsing so we can parse them in parallel — each chunk is a few
    # hundred KB of pure-Python tree walking, GIL-bound but cheap to
    # overlap with the next chunk's JSON decode (which releases the GIL).
    chunks = list(iter_wrb_chunks(response.text))
    if not chunks:
        return []
    parsed = parallel_map(parse_booking_chunk, chunks)
    options: list[BookingOption] = []
    for chunk_options in parsed:
        options.extend(chunk_options)
    return options

search(filters: FlightSearchFilters, top_n: int = 5, currency: str | None = None, language: str | None = None, country: str | None = None) -> list[FlightResult | tuple[FlightResult, ...]] | None

Search for flights using the given :class:FlightSearchFilters.

PARAMETER DESCRIPTION
filters

Full search descriptor (airports, dates, preferences).

TYPE: FlightSearchFilters

top_n

Number of outbound options to expand when chasing a round-trip or multi-city itinerary. Must be between 1 and 10 (inclusive). A round trip costs 1 + top_n page fetches — one for the outbound search plus one per candidate expanded into return flights — so raising it surfaces more airlines (the default sort otherwise expands only the cheapest top_n outbounds, which are often all from the same carrier) at the cost of more requests. Ignored for one-way searches.

TYPE: int DEFAULT: 5

currency

Optional ISO 4217 currency code (curr URL param).

TYPE: str | None DEFAULT: None

language

Optional BCP-47 language code (hl URL param).

TYPE: str | None DEFAULT: None

country

Optional ISO 3166-1 alpha-2 country code (gl URL param).

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
list[FlightResult | tuple[FlightResult, ...]] | None

For one-way trips, a list of :class:FlightResult. For

list[FlightResult | tuple[FlightResult, ...]] | None

round-trip / multi-city, a list of tuples of

list[FlightResult | tuple[FlightResult, ...]] | None

class:FlightResult (one per segment, in order). None

list[FlightResult | tuple[FlightResult, ...]] | None

when no results.

RAISES DESCRIPTION
ValueError

top_n is not an int (bool included — a Python int subclass, rejected explicitly rather than silently treated as 0/1) or is outside 1..10 inclusive.

Exception

HTTP failure or unparseable response.

Source code in fli/search/flights.py
def search(
    self,
    filters: FlightSearchFilters,
    top_n: int = 5,
    currency: str | None = None,
    language: str | None = None,
    country: str | None = None,
) -> list[FlightResult | tuple[FlightResult, ...]] | None:
    """Search for flights using the given :class:`FlightSearchFilters`.

    Args:
        filters: Full search descriptor (airports, dates, preferences).
        top_n: Number of outbound options to expand when chasing a
            round-trip or multi-city itinerary. Must be between 1 and
            10 (inclusive). A round trip costs ``1 + top_n`` page
            fetches — one for the outbound search plus one per
            candidate expanded into return flights — so raising it
            surfaces more airlines (the default sort otherwise expands
            only the cheapest ``top_n`` outbounds, which are often all
            from the same carrier) at the cost of more requests.
            Ignored for one-way searches.
        currency: Optional ISO 4217 currency code (``curr`` URL param).
        language: Optional BCP-47 language code (``hl`` URL param).
        country: Optional ISO 3166-1 alpha-2 country code (``gl`` URL param).

    Returns:
        For one-way trips, a list of :class:`FlightResult`. For
        round-trip / multi-city, a list of tuples of
        :class:`FlightResult` (one per segment, in order). ``None``
        when no results.

    Raises:
        ValueError: ``top_n`` is not an ``int`` (``bool`` included — a
            Python ``int`` subclass, rejected explicitly rather than
            silently treated as ``0``/``1``) or is outside ``1..10``
            inclusive.
        Exception: HTTP failure or unparseable response.

    """
    # Reset before any code below can raise, so a search that raises
    # never leaves a stale True from an earlier call on this instance.
    self._sparse_passenger_mix = False

    if not isinstance(top_n, int) or isinstance(top_n, bool) or not 1 <= top_n <= 10:
        raise ValueError(
            f"top_n must be an integer between 1 and 10 (inclusive); got {top_n!r} "
            f"({type(top_n).__name__}). It controls how many outbound options a "
            "round-trip search expands into return-flight combinations — cost is "
            "1 + top_n page fetches, hence the cap."
        )
    # Per-search, lock-guarded — never instance state, which the round
    # trip's parallel expansion workers (below) would trample mid-flight.
    tracker = _RowCountTracker()
    flights = self._fetch_flights(
        filters,
        currency=currency,
        language=language,
        country=country,
        capture_session=True,
        tracker=tracker,
    )
    if flights is None:
        self._warn_if_sparse_passenger_mix(filters, tracker)
        return None
    if filters.trip_type == TripType.ONE_WAY:
        return flights
    combos = self._expand_multi_leg(
        flights,
        filters,
        top_n=top_n,
        currency=currency,
        language=language,
        country=country,
        tracker=tracker,
    )
    if not combos:
        self._warn_if_sparse_passenger_mix(filters, tracker)
    return combos

FlightSearchFilters

A simplified interface for flight search parameters.

fli.models.google_flights.FlightSearchFilters

Bases: BaseModel

Complete set of filters for flight search.

This model matches required Google Flights' API structure.

airlines: list[Airline] | None = None class-attribute instance-attribute

airlines_exclude: list[Airline] | None = None class-attribute instance-attribute

alliances: list[Alliance] | None = None class-attribute instance-attribute

alliances_exclude: list[Alliance] | None = None class-attribute instance-attribute

bags: BagsFilter | None = None class-attribute instance-attribute

emissions: EmissionsFilter = EmissionsFilter.ALL class-attribute instance-attribute

exclude_basic_economy: bool = False class-attribute instance-attribute

flight_segments: list[FlightSegment] instance-attribute

layover_restrictions: LayoverRestrictions | None = None class-attribute instance-attribute

max_duration: PositiveInt | None = None class-attribute instance-attribute

passenger_info: PassengerInfo instance-attribute

price_limit: PriceLimit | None = None class-attribute instance-attribute

seat_type: SeatType = SeatType.ECONOMY class-attribute instance-attribute

show_all_results: bool = True class-attribute instance-attribute

sort_by: SortBy = SortBy.BEST class-attribute instance-attribute

stops: MaxStops = MaxStops.ANY class-attribute instance-attribute

trip_type: TripType = TripType.ONE_WAY class-attribute instance-attribute

encode() -> str

URL encode the formatted filters for API request.

Source code in fli/models/google_flights/flights.py
def encode(self) -> str:
    """URL encode the formatted filters for API request."""
    formatted_filters = self.format()
    # First convert the formatted filters to a JSON string
    formatted_json = json.dumps(formatted_filters, separators=(",", ":"))
    # Then wrap it in a list with null
    wrapped_filters = [None, formatted_json]
    # Finally, encode the whole thing
    return urllib.parse.quote(json.dumps(wrapped_filters, separators=(",", ":")))

format() -> list

Format filters into Google Flights API structure.

This method converts the FlightSearchFilters model into the specific nested list/dict structure required by Google Flights' API.

The output format matches Google Flights' internal API structure, with careful handling of nested arrays and proper serialization of enums and model objects.

RETURNS DESCRIPTION
list

A formatted list structure ready for the Google Flights API request

TYPE: list

Source code in fli/models/google_flights/flights.py
def format(self) -> list:
    """Format filters into Google Flights API structure.

    This method converts the FlightSearchFilters model into the specific nested list/dict
    structure required by Google Flights' API.

    The output format matches Google Flights' internal API structure, with careful handling
    of nested arrays and proper serialization of enums and model objects.

    Returns:
        list: A formatted list structure ready for the Google Flights API request

    """

    def serialize(obj):
        if isinstance(obj, Airport) or isinstance(obj, Airline):
            return obj.name.removeprefix("_")
        if isinstance(obj, Enum):
            return obj.value
        if isinstance(obj, list):
            return [serialize(item) for item in obj]
        if isinstance(obj, dict):
            return {key: serialize(value) for key, value in obj.items()}
        if isinstance(obj, BaseModel):
            return serialize(obj.dict(exclude_none=True))
        return obj

    # Format flight segments. Google's UI classifies segments with a
    # trailing int at position 14:
    #   - 3 = outbound (first leg, or only leg of a one-way / multi-city)
    #   - 1 = return (second leg of a round-trip)
    # Empirically Google's `GetShoppingResults` accepts a uniform `3`
    # for both segments without errors, but `GetBookingResults`
    # rejects the request with INVALID_ARGUMENT unless the classifier
    # matches the UI's pattern (verified May 2026 by diffing the
    # browser's POST body against our own).
    formatted_segments = []
    for seg_idx, segment in enumerate(self.flight_segments):
        # Format airport codes with correct nesting
        segment_filters = [
            [
                [
                    [serialize(airport[0]), serialize(airport[1])]
                    for airport in segment.departure_airport
                ]
            ],
            [
                [
                    [serialize(airport[0]), serialize(airport[1])]
                    for airport in segment.arrival_airport
                ]
            ],
        ]

        # Time restrictions
        if segment.time_restrictions:
            time_filters = [
                segment.time_restrictions.earliest_departure,
                segment.time_restrictions.latest_departure,
                segment.time_restrictions.earliest_arrival,
                segment.time_restrictions.latest_arrival,
            ]
        else:
            time_filters = None

        # Airlines include — accepts a mix of airline IATA codes and
        # alliance identifier strings ("ONEWORLD" / "SKYTEAM" /
        # "STAR_ALLIANCE"). Sort airline codes for deterministic encoding;
        # append alliance strings after, also sorted, so the request body
        # is stable across runs (only matters for snapshot tests).
        airlines_filters: list | None = None
        include_tokens: list[str] = []
        if self.airlines:
            include_tokens.extend(
                serialize(a) for a in sorted(self.airlines, key=lambda x: x.value)
            )
        if self.alliances:
            include_tokens.extend(sorted(a.value for a in self.alliances))
        if include_tokens:
            airlines_filters = include_tokens

        # Airlines exclude — same dual-purpose list shape (codes + alliance
        # names), stored at segment[5]. Empirically discovered May 2026.
        exclude_filters: list | None = None
        exclude_tokens: list[str] = []
        if self.airlines_exclude:
            exclude_tokens.extend(
                serialize(a) for a in sorted(self.airlines_exclude, key=lambda x: x.value)
            )
        if self.alliances_exclude:
            exclude_tokens.extend(sorted(a.value for a in self.alliances_exclude))
        if exclude_tokens:
            exclude_filters = exclude_tokens

        # Layover restrictions
        layover_airports = (
            [serialize(a) for a in self.layover_restrictions.airports]
            if self.layover_restrictions and self.layover_restrictions.airports
            else None
        )
        layover_min_duration = (
            self.layover_restrictions.min_duration if self.layover_restrictions else None
        )
        layover_max_duration = (
            self.layover_restrictions.max_duration if self.layover_restrictions else None
        )

        # Selected flight (to fetch return/next-leg flights)
        selected_flights = None
        is_multi_leg = self.trip_type in (TripType.ROUND_TRIP, TripType.MULTI_CITY)
        if is_multi_leg and segment.selected_flight is not None:
            selected_flights = [
                [
                    serialize(leg.departure_airport),
                    serialize(leg.departure_datetime.strftime("%Y-%m-%d")),
                    serialize(leg.arrival_airport),
                    None,
                    serialize(leg.airline),
                    serialize(leg.flight_number),
                ]
                for leg in segment.selected_flight.legs
            ]

        # Emissions filter
        emissions_filter = (
            [self.emissions.value] if self.emissions != EmissionsFilter.ALL else None
        )

        # Segment classifier: 3 for outbound (or only leg), 1 for return.
        is_return = self.trip_type == TripType.ROUND_TRIP and seg_idx > 0
        classifier = 1 if is_return else 3

        segment_formatted = [
            segment_filters[0],  # 0: departure airport
            segment_filters[1],  # 1: arrival airport
            time_filters,  # 2: time restrictions [edep, ldep, earr, larr]
            serialize(self.stops.value),  # 3: stops int
            airlines_filters,  # 4: airline / alliance INCLUDE list
            exclude_filters,  # 5: airline / alliance EXCLUDE list
            segment.travel_date,  # 6: travel date
            [self.max_duration] if self.max_duration else None,  # 7: max duration
            selected_flights,  # 8: selected flight (next-leg fetch)
            layover_airports,  # 9: layover airport include list
            None,  # 10: ? (rejects scalars; no observed effect)
            layover_min_duration,  # 11: min layover duration (mins)
            layover_max_duration,  # 12: max layover duration (mins)
            emissions_filter,  # 13: emissions filter [1]=less emissions
            classifier,  # 14: classifier (3=outbound, 1=return)
        ]
        formatted_segments.append(segment_formatted)

    # Bags filter
    bags_filter = [self.bags.checked_bags, int(self.bags.carry_on)] if self.bags else None

    # The browser uses a wrapper nesting where outer[1] = [[], [main], ...fields...]
    # with self-transfer at wrapper[6] and basic economy at wrapper[15].
    # However, the wrapper format returns empty results through our API client
    # (likely requires browser cookies/headers). We use a flat format instead
    # which the API accepts. NOTE: Self-transfer cannot be toggled in the flat format.
    #
    # Main settings (filters[1]) index map:
    #   0:  unknown - seemingly no effect (tested 0-3, [], "en")
    #   1:  unknown - seemingly no effect (tested "USD"/"EUR"/"GBP"/"JPY");
    #       currency appears to be determined by IP/locale
    #   2:  trip type
    #   3:  unknown - seemingly no effect (tested 0-3)
    #   4:  unknown - seemingly no effect as [] or None; 400s on scalars
    #   5:  seat/cabin type
    #   6:  passenger counts [adults, children, infants_lap, infants_seat]
    #   7:  price limit [None, max_price]
    #   8:  unknown - seemingly no effect (tested 0-3, arrays)
    #   9:  unknown - seemingly no effect (tested 0-3, arrays)
    #   10: bags filter [checked_bags, carry_on]
    #   11: unknown - seemingly no effect (tested 0-3, arrays)
    #   12: unknown - seemingly no effect (tested 0-3, arrays)
    #   13: flight segments
    #   14-16: unknown - seemingly no effect
    #   17: unknown - seemingly no effect (hardcoded to 1)
    #   18-27: unknown - seemingly no effect
    #   28: exclude basic economy (0=allow, 1=exclude)
    #
    filters = [
        [],  # outer[0]
        [
            None,  # [0] seemingly no effect
            None,  # [1] seemingly no effect (not currency)
            serialize(self.trip_type.value),
            None,  # [3] seemingly no effect
            [],  # [4] seemingly no effect
            serialize(self.seat_type.value),
            [
                self.passenger_info.adults,
                self.passenger_info.children,
                self.passenger_info.infants_on_lap,
                self.passenger_info.infants_in_seat,
            ],
            [None, self.price_limit.max_price] if self.price_limit else None,
            None,  # [8] seemingly no effect
            None,  # [9] seemingly no effect
            bags_filter,  # [10] bags filter [checked_bags, carry_on]
            None,  # [11] seemingly no effect
            None,  # [12] seemingly no effect
            formatted_segments,
            None,  # [14] seemingly no effect
            None,  # [15] seemingly no effect
            None,  # [16] seemingly no effect
            1,  # [17] seemingly no effect (hardcoded to 1)
            None,  # [18] seemingly no effect
            None,  # [19] seemingly no effect
            None,  # [20] seemingly no effect
            None,  # [21] seemingly no effect
            None,  # [22] seemingly no effect
            None,  # [23] seemingly no effect
            None,  # [24] seemingly no effect
            None,  # [25] seemingly no effect
            None,  # [26] seemingly no effect
            None,  # [27] seemingly no effect
            1 if self.exclude_basic_economy else 0,
        ],
        serialize(self.sort_by.value),  # outer[2] sort mode
        1 if self.show_all_results else 0,  # outer[3] 0=~30, 1=all results
        0,  # outer[4] seemingly no effect
        1,  # outer[5] seemingly no effect
    ]

    return filters

validate_segment_date_order() -> FlightSearchFilters

Validate that each leg departs no earlier than the one before it.

Nothing else catches a return date that precedes the outbound date: it used to be rejected only when it happened to land in the past, so a backwards round trip in the future was passed straight to Google.

Source code in fli/models/google_flights/flights.py
@model_validator(mode="after")
def validate_segment_date_order(self) -> "FlightSearchFilters":
    """Validate that each leg departs no earlier than the one before it.

    Nothing else catches a return date that precedes the outbound date: it
    used to be rejected only when it happened to land in the past, so a
    backwards round trip in the future was passed straight to Google.
    """
    if self.trip_type not in (TripType.ROUND_TRIP, TripType.MULTI_CITY):
        return self

    dates = [
        datetime.strptime(segment.travel_date, "%Y-%m-%d").date()
        for segment in self.flight_segments
    ]
    for index, (previous, current) in enumerate(zip(dates, dates[1:], strict=False), start=2):
        if current < previous:
            if self.trip_type == TripType.ROUND_TRIP:
                raise ValueError(
                    f"Return date ({current}) cannot be before departure date ({previous})"
                )
            raise ValueError(
                f"Segment {index} travel date ({current}) cannot be before "
                f"segment {index - 1} travel date ({previous})"
            )
    return self

Search functionality for finding the cheapest dates to fly.

SearchDates

fli.search.dates.SearchDates()

Date-based flight search implementation.

This class provides methods to search for flight prices across a date range, useful for finding the cheapest dates to fly.

Initialize the search client for date-based searches.

Source code in fli/search/dates.py
def __init__(self):
    """Initialize the search client for date-based searches."""
    self.client = get_client()
    # Set at the start of every search() call — see the
    # sparse_passenger_mix property.
    self._sparse_passenger_mix: bool = False

client = get_client() instance-attribute

sparse_passenger_mix: bool property

Whether the sparse-passenger-mix warning fired on the most recent search().

True exactly when the empty sweep this instance last returned (or raised out of) was consistent with Google's client-side pricing gap for children/infants rather than the caller's own filters — see _warn_if_sparse_passenger_mix. Reset to False at the start of every :meth:search call, including ones that raise, so a stale True from an earlier call never leaks into a later one.

Reflects only the last completed :meth:search call on this instance and is not meant for instances shared across concurrent searches — two overlapping :meth:search calls on one SearchDates would race on this attribute. Not currently read by the MCP server or CLI (unlike SearchFlights.sparse_passenger_mix); exposed here for symmetry with the warning this class already logs.

__parse_currency(item: list[list] | list | None) -> str | None staticmethod

Parse the returned currency code from the API response.

Source code in fli/search/dates.py
@staticmethod
def __parse_currency(item: list[list] | list | None) -> str | None:
    """Parse the returned currency code from the API response."""
    try:
        if item and isinstance(item, list) and len(item) > 2:
            if isinstance(item[2], list) and len(item[2]) > 1:
                return extract_currency_from_price_token(item[2][1])
    except (IndexError, TypeError, ValueError):
        pass

    return None

__parse_date(item: list[list] | list | None, trip_type: TripType) -> tuple[datetime] | tuple[datetime, datetime] staticmethod

Parse date data from the API response.

PARAMETER DESCRIPTION
item

Raw date data from the API response

TYPE: list[list] | list | None

trip_type

Trip type (one-way or round-trip)

TYPE: TripType

RETURNS DESCRIPTION
tuple[datetime] | tuple[datetime, datetime]

Tuple of datetime objects

Source code in fli/search/dates.py
@staticmethod
def __parse_date(
    item: list[list] | list | None, trip_type: TripType
) -> tuple[datetime] | tuple[datetime, datetime]:
    """Parse date data from the API response.

    Args:
        item: Raw date data from the API response
        trip_type: Trip type (one-way or round-trip)

    Returns:
        Tuple of datetime objects

    """
    if trip_type == TripType.ONE_WAY:
        return (datetime.strptime(item[0], "%Y-%m-%d"),)
    else:
        return (
            datetime.strptime(item[0], "%Y-%m-%d"),
            datetime.strptime(item[1], "%Y-%m-%d"),
        )

__parse_price(item: list[list] | list | None) -> float | None staticmethod

Parse price data from the API response.

PARAMETER DESCRIPTION
item

Raw price data from the API response

TYPE: list[list] | list | None

RETURNS DESCRIPTION
float | None

Float price value if valid, None if invalid or missing

Source code in fli/search/dates.py
@staticmethod
def __parse_price(item: list[list] | list | None) -> float | None:
    """Parse price data from the API response.

    Args:
        item: Raw price data from the API response

    Returns:
        Float price value if valid, None if invalid or missing

    """
    try:
        if item and isinstance(item, list) and len(item) > 2:
            if isinstance(item[2], list) and len(item[2]) > 0:
                if isinstance(item[2][0], list) and len(item[2][0]) > 1:
                    return float(item[2][0][1])
    except (IndexError, TypeError, ValueError):
        pass

    return None

search(filters: DateSearchFilters, currency: str | None = None, language: str | None = None, country: str | None = None) -> list[DatePrice] | None

Search for flight prices across a date range and search parameters.

PARAMETER DESCRIPTION
filters

Search parameters including date range, airports, and preferences

TYPE: DateSearchFilters

currency

Optional ISO 4217 currency code (e.g. "EUR") to bill prices in.

TYPE: str | None DEFAULT: None

language

Optional BCP-47 language code passed via the hl URL param.

TYPE: str | None DEFAULT: None

country

Optional ISO 3166-1 alpha-2 country code passed via the gl URL param.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
list[DatePrice] | None

List of DatePrice objects containing date and price pairs, or None if no results

RAISES DESCRIPTION
ValueError

The range covers more than :data:MAX_DATES_PER_SEARCH dates, which would cost one page fetch each.

SearchClientError

Every date in the range failed to load, or nothing priced because at least half the attempted dates never loaded — see :meth:_collect.

Notes

Every date in the range costs its own search-page fetch — the page serves no calendar grid — so the range is capped at :data:MAX_DATES_PER_SEARCH dates. Internally it is still split into MAX_DAYS_PER_SEARCH-day chunk filters, but all dates are priced by a single flat parallel map.

Source code in fli/search/dates.py
def search(
    self,
    filters: DateSearchFilters,
    currency: str | None = None,
    language: str | None = None,
    country: str | None = None,
) -> list[DatePrice] | None:
    """Search for flight prices across a date range and search parameters.

    Args:
        filters: Search parameters including date range, airports, and preferences
        currency: Optional ISO 4217 currency code (e.g. ``"EUR"``) to bill prices in.
        language: Optional BCP-47 language code passed via the ``hl`` URL param.
        country: Optional ISO 3166-1 alpha-2 country code passed via the ``gl`` URL param.

    Returns:
        List of DatePrice objects containing date and price pairs, or None if no results

    Raises:
        ValueError: The range covers more than :data:`MAX_DATES_PER_SEARCH`
            dates, which would cost one page fetch each.
        SearchClientError: Every date in the range failed to load, or
            nothing priced because at least half the attempted dates
            never loaded — see :meth:`_collect`.

    Notes:
        Every date in the range costs its own search-page fetch — the page
        serves no calendar grid — so the range is capped at
        :data:`MAX_DATES_PER_SEARCH` dates. Internally it is still split
        into ``MAX_DAYS_PER_SEARCH``-day chunk filters, but all dates are
        priced by a single flat parallel map.

    """
    # Reset before any code below can raise, so a search that raises
    # never leaves a stale True from an earlier call on this instance.
    self._sparse_passenger_mix = False

    dropped = unsupported_filters(filters)
    if dropped:
        logger.warning(
            "Filters not supported by the search-page transport, ignored: %s",
            ", ".join(dropped),
        )

    from_date = datetime.strptime(filters.from_date, "%Y-%m-%d")
    to_date = datetime.strptime(filters.to_date, "%Y-%m-%d")

    # Build every chunk descriptor up front so the per-date requests share
    # no mutable state. This both enables parallel execution and fixes a
    # latent bug in the previous sequential version: each chunk rewrote
    # ``filters.flight_segments[*].travel_date`` in place, so the
    # second-and-later chunks had segment dates that no longer matched
    # ``current_from``.
    tasks = [
        (chunk, day)
        for chunk in self._build_chunk_filters(filters, from_date, to_date)
        for day in self._days_in(chunk)
    ]

    if len(tasks) > MAX_DATES_PER_SEARCH:
        raise ValueError(
            f"This date search covers {len(tasks)} dates, above the "
            f"{MAX_DATES_PER_SEARCH}-date limit. Google's search page serves no "
            "calendar grid, so every date costs its own full page fetch. "
            "Narrow the range (or run several smaller searches)."
        )

    # One flat map over every date. Nesting a second ``parallel_map``
    # inside each chunk's worker deadlocks: both levels share one bounded
    # pool, so the outer tasks can occupy every worker while blocking on
    # inner tasks that can never be scheduled.
    #
    # ``health`` is the sweep's circuit breaker: a client that is being
    # blocked fails identically on every date, and each failed date costs
    # up to nine HTTP requests once the client's retries and the page
    # retry multiply. Queued dates check it before spending anything.
    health = _SweepHealth(SWEEP_FAILURE_THRESHOLD)
    outcomes = parallel_map(
        lambda task: self._price_one_date(
            task[0],
            task[1],
            currency=currency,
            language=language,
            country=country,
            health=health,
        ),
        tasks,
    )
    result = self._collect(outcomes, len(tasks), skipped=health.skipped)
    self._sparse_passenger_mix = self._warn_if_sparse_passenger_mix(
        outcomes, result, filters.passenger_info
    )
    return result

DatePrice

fli.search.dates.DatePrice

Bases: BaseModel

Flight price for a specific date.

currency: str | None = None class-attribute instance-attribute

date: tuple[datetime] | tuple[datetime, datetime] instance-attribute

price: float instance-attribute

Examples

from fli.search import SearchFlights
from fli.models import Airport, SeatType, FlightSearchFilters, FlightSegment, PassengerInfo

# Create filters
filters = FlightSearchFilters(
    passenger_info=PassengerInfo(adults=1),
    flight_segments=[
        FlightSegment(
            departure_airport=[[Airport.JFK, 0]],
            arrival_airport=[[Airport.LAX, 0]],
            travel_date="2026-06-01",
        )
    ],
    seat_type=SeatType.ECONOMY
)

# Search flights
search = SearchFlights()
results = search.search(filters)
from fli.search import SearchDates
from fli.models import DateSearchFilters, Airport, FlightSegment, PassengerInfo

# Create filters
filters = DateSearchFilters(
    passenger_info=PassengerInfo(adults=1),
    flight_segments=[
        FlightSegment(
            departure_airport=[[Airport.JFK, 0]],
            arrival_airport=[[Airport.LAX, 0]],
            travel_date="2026-06-01",
        )
    ],
    from_date="2026-06-01",
    to_date="2026-06-30"
)

# Search dates
search = SearchDates()
results = search.search(filters)

Running These Examples

You can find complete, runnable versions of these examples in the examples/python/ directory:

# Run with uv (recommended)
uv run python examples/python/basic_one_way_search.py
uv run python examples/python/date_range_search.py

# Or install dependencies and run directly
pip install pydantic curl_cffi httpx
python examples/python/basic_one_way_search.py

For more advanced examples, see:

  • examples/python/complex_flight_search.py - Advanced filtering
  • examples/python/result_processing.py - Data analysis
  • examples/python/error_handling_with_retries.py - Robust error handling

HTTP Client

The underlying HTTP client used for API requests.

Client

fli.search.client.Client(calls_per_second: int = DEFAULT_CALLS_PER_SECOND)

HTTP client with built-in rate limiting, retry and user agent impersonation functionality.

Sessions are kept per-thread because curl_cffi.requests.Session is not thread-safe — concurrent post/get calls from different threads each get their own libcurl handle. The shared :class:TokenBucketRateLimiter enforces the global 10 req/sec budget across all of them.

Initialise the shared rate limiter and per-thread session storage.

Source code in fli/search/client.py
def __init__(
    self,
    calls_per_second: int = DEFAULT_CALLS_PER_SECOND,
):
    """Initialise the shared rate limiter and per-thread session storage."""
    self._sessions = threading.local()
    self._rate_limiter = TokenBucketRateLimiter(calls=calls_per_second, period=1.0)

DEFAULT_HEADERS = {'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'} class-attribute instance-attribute

__del__()

Best-effort cleanup of the main-thread session (others die with their thread).

Source code in fli/search/client.py
def __del__(self):
    """Best-effort cleanup of the main-thread session (others die with their thread)."""
    session = getattr(self._sessions, "session", None) if hasattr(self, "_sessions") else None
    if session is not None:
        try:
            session.close()
        except Exception:  # noqa: BLE001 — destruction-time best effort
            pass

get(url: str, **kwargs: Any) -> Response

Make a rate-limited GET request with automatic retries.

Source code in fli/search/client.py
@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(),
    retry=retry_if_not_exception_type(SearchCertificateError),
    reraise=True,
)
def get(self, url: str, **kwargs: Any) -> Response:
    """Make a rate-limited GET request with automatic retries."""
    self._rate_limiter.acquire()
    kwargs.setdefault("timeout", REQUEST_TIMEOUT)
    try:
        response = self._session().get(url, **kwargs)
        response.raise_for_status()
        return response
    except Exception as e:
        raise _wrap_request_error("GET", url, e) from e

post(url: str, **kwargs: Any) -> Response

Make a rate-limited POST request with automatic retries.

Source code in fli/search/client.py
@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(),
    retry=retry_if_not_exception_type(SearchCertificateError),
    reraise=True,
)
def post(self, url: str, **kwargs: Any) -> Response:
    """Make a rate-limited POST request with automatic retries."""
    self._rate_limiter.acquire()
    kwargs.setdefault("timeout", REQUEST_TIMEOUT)
    try:
        response = self._session().post(url, **kwargs)
        response.raise_for_status()
        return response
    except Exception as e:
        raise _wrap_request_error("POST", url, e) from e