Skip to content

fix(aviation): fail-closed flight price search; gate demo behind explicit opt-in (#3756)#3795

Merged
koala73 merged 4 commits into
mainfrom
fix/3756-flight-prices-fail-closed
May 18, 2026
Merged

fix(aviation): fail-closed flight price search; gate demo behind explicit opt-in (#3756)#3795
koala73 merged 4 commits into
mainfrom
fix/3756-flight-prices-fail-closed

Conversation

@koala73

@koala73 koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner

Summary

Per issue #3756, the flight-price search route silently fell back to randomized demo quotes whenever `TRAVELPAYOUTS_API_TOKEN` was missing, the upstream call failed, or the provider returned zero results. UI labelled this with a single 11px gray "Indicative prices" footnote — indistinguishable from live data at a glance for a self-hoster following the README's "no env vars required" promise.

Change

Server (`server/worldmonitor/aviation/v1/search-flight-prices.ts`):

  • Demo data now requires explicit `AVIATION_DEMO_PRICES=1` server env opt-in.
  • Default path fails closed and emits distinct error discriminators on the response: `missing_credentials` / `no_results` / `upstream_error` / `""` (ok).

Proto (`search_flight_prices.proto`):

  • Adds `degraded: bool` + `error: string` fields to `SearchFlightPricesResponse` — mirrors the `searchGoogleFlights` pattern that already exists in the same module.

Service layer (`src/services/aviation/index.ts`):

  • `fetchFlightPrices` passes through `degraded` + `error` + `isIndicative` from the server response so the UI can distinguish states.
  • Circuit-breaker fallback is now `{ degraded: true, error: 'upstream_error', isDemoMode: false }` instead of synthetic demo.

UI (`src/components/AviationCommandBar.ts`):

  • Per-state error messages instead of "No prices found":
    • `missing_credentials` → "Live flight pricing requires `TRAVELPAYOUTS_API_TOKEN`. Search Google Flights instead →"
    • `upstream_error` → "Flight pricing provider temporarily unavailable. Search Google Flights instead →"
    • `no_results` → "No live prices found for IST → LHR."
  • Demo mode (now opt-in only) renders an unmistakable amber banner: "⚠ DEMO DATA — synthetic distance-based estimates, not live market quotes" instead of the prior 11px footnote.

README (`README.md`):

  • The "no environment variables required" line now clarifies that flight pricing requires `TRAVELPAYOUTS_API_TOKEN`; self-hosters without it see an actionable error rather than fake prices.

Test plan

  • `npx tsx --test tests/search-flight-prices-fail-closed.test.mts` — 4/4 pass
  • `npm run typecheck` — clean
  • `make generate` — proto + client + server + OpenAPI regen consistent

Tests cover all four reachable paths:

  1. Default-off + missing credentials → empty quotes, `error: 'missing_credentials'`
  2. Default-off + upstream empty/failure → empty quotes, `error: 'no_results'`
  3. Demo opt-in + missing credentials → demo quotes, `isDemoMode: true`, `error: 'missing_credentials'`
  4. Demo opt-in + upstream empty/failure → demo quotes, `isDemoMode: true`, `error: 'no_results'`

The `upstream_error` discriminator is reserved in the proto for future provider changes that surface fetch failures explicitly; not directly testable today because the current Travelpayouts provider catches them internally and surfaces them as empty data (documented in the handler).

Acceptance criteria check (from issue)

  • Missing provider credentials cannot silently produce synthetic quotes in normal operation
  • Upstream failure and zero-results are distinguishable from demo mode (demo is opt-in only and always banner-flagged)
  • Demo data is opt-in and visibly non-production across the request and UI path
  • Tests cover missing-token, upstream-error/empty-result, and demo-flag behavior

Closes #3756

…icit opt-in (#3756)

The flight-price search route silently fell back to randomized demo
quotes whenever TRAVELPAYOUTS_API_TOKEN was missing, the upstream call
failed, or the provider returned zero results. UI labelled this with
a single 11px gray "Indicative prices" footnote — indistinguishable
from live data at a glance for a self-hoster following the README.

Demo data now requires explicit AVIATION_DEMO_PRICES=1 server env opt-in.
Default path fails closed and surfaces the failure mode to the UI:

- missing_credentials  → "Live flight pricing requires
                          TRAVELPAYOUTS_API_TOKEN. Search Google Flights
                          instead →" with a deep link
- no_results           → "No live prices found for IST → LHR"
                          (also covers swallowed upstream errors —
                          the Travelpayouts provider catches fetch
                          failures internally and surfaces them as
                          empty data; documented in the handler)
- upstream_error       → "Flight pricing provider temporarily
                          unavailable. Search Google Flights instead →"
                          (reserved for synchronous handler failures
                          that bubble up out of the provider)
- ok                   → live quotes rendered normally

When AVIATION_DEMO_PRICES=1 IS set, demo quotes render with a
prominent amber banner: "⚠ DEMO DATA — synthetic distance-based
estimates, not live market quotes". No more silent footnote.

Proto adds `degraded: bool` + `error: string` fields to
SearchFlightPricesResponse — mirrors the searchGoogleFlights pattern.

README updated to clarify that flight pricing requires the env var;
self-hosters without it now see an actionable error rather than fake
prices.

Tests cover all four reachable paths (default-off × {missing_creds,
no_results}, demo-on × {missing_creds, no_results}). The `upstream_error`
discriminator is reserved in the proto for future provider changes that
surface fetch failures explicitly; not directly testable today because
the current Travelpayouts provider swallows them internally.
@vercel

vercel Bot commented May 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 18, 2026 11:19am

Request Review

@mintlify

mintlify Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview May 18, 2026, 10:17 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@greptile-apps

greptile-apps Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR addresses issue #3756 by making the flight-price search route fail-closed: demo data now requires explicit AVIATION_DEMO_PRICES=1 opt-in, and the default path returns distinct missing_credentials / no_results / upstream_error discriminators so the UI can render actionable per-state messages instead of silently serving synthetic quotes.

  • Server handler (search-flight-prices.ts) and proto (search_flight_prices.proto) add degraded: bool + error: string to the response, mirroring the searchGoogleFlights pattern.
  • Service layer (src/services/aviation/index.ts) threads the new fields through and updates the circuit-breaker fallback to degraded: true, isDemoMode: false, but omits a shouldCache guard that would prevent degraded states from being persisted to localStorage.
  • UI (AviationCommandBar.ts) replaces the 11px footnote with per-state messages and an unmistakable amber demo banner; README is updated to document the token requirement.

Confidence Score: 3/5

The core fail-closed logic on the server is correct, but the client-side circuit breaker caches degraded error responses to localStorage for 10 minutes, which can leave the UI stuck showing the old error state after the server condition changes.

The fetchFlightPrices call omits a shouldCache guard, so every degraded:true response (e.g. missing_credentials for self-hosters without a token) is persisted to localStorage for the full 10-minute TTL. A self-hoster who sets the token and refreshes will still see the credentials-required message for up to 10 minutes. Every other breaker in the file that can return empty results guards against this with shouldCache.

src/services/aviation/index.ts — the shouldCache guard on breakerPrices.execute

Important Files Changed

Filename Overview
server/worldmonitor/aviation/v1/search-flight-prices.ts Handler correctly implements fail-closed behavior: token absent → missing_credentials, empty upstream → no_results, throw → upstream_error; demo mode strictly gated behind AVIATION_DEMO_PRICES=1.
src/services/aviation/index.ts fetchFlightPrices updates the circuit-breaker fallback correctly, but missing shouldCache guard means degraded/error responses are persisted to localStorage for 10 min, unlike fetchFlightDelays which uses shouldCache: (r) => r.length > 0.
src/components/AviationCommandBar.ts Per-state error messages and amber demo banner correctly implemented; no_results path omits the Google Flights fallback link provided for the other two degraded states.
proto/worldmonitor/aviation/v1/search_flight_prices.proto Adds degraded: bool and error: string fields to SearchFlightPricesResponse, matching the searchGoogleFlights pattern; generated stubs are consistent.
tests/search-flight-prices-fail-closed.test.mts 4 tests cover all documented reachable paths; env/fetch teardown is correct; upstream_error omission is documented inline.

Sequence Diagram

sequenceDiagram
    participant UI as AviationCommandBar
    participant Svc as fetchFlightPrices (CB)
    participant Server as search-flight-prices.ts
    participant TP as Travelpayouts

    UI->>Svc: fetchFlightPrices(origin, dest, date)
    alt Circuit breaker cache hit (up to 10 min, incl. degraded)
        Svc-->>UI: "cached { degraded, error, quotes }"
    else Cache miss
        Svc->>Server: searchFlightPrices RPC
        alt TRAVELPAYOUTS_API_TOKEN unset
            Server-->>Svc: "{ quotes:[], degraded:true, error:missing_credentials }"
        else Token set
            Server->>TP: searchPricesTravelpayouts(...)
            alt Quotes returned
                TP-->>Server: "{ quotes: [...] }"
                Server-->>Svc: "{ quotes:[...], degraded:false, error:empty }"
            else Empty result
                TP-->>Server: "{ quotes: [] }"
                alt "AVIATION_DEMO_PRICES=1"
                    Server-->>Svc: "{ quotes:[demo...], isDemoMode:true, degraded:true, error:no_results }"
                else Default fail-closed
                    Server-->>Svc: "{ quotes:[], degraded:true, error:no_results }"
                end
            else Throws
                TP--xServer: exception
                alt "AVIATION_DEMO_PRICES=1"
                    Server-->>Svc: "{ quotes:[demo...], isDemoMode:true, degraded:true, error:upstream_error }"
                else Default
                    Server-->>Svc: "{ quotes:[], degraded:true, error:upstream_error }"
                end
            end
        end
        Svc-->>UI: result cached 10 min including degraded states
    end
    UI->>UI: render per-state message or quotes plus optional demo banner
Loading

Reviews (1): Last reviewed commit: "fix(aviation): fail-closed flight price ..." | Re-trigger Greptile

Comment thread src/services/aviation/index.ts Outdated
provider: resp.provider,
};
}, { quotes: [], isDemoMode: true, isIndicative: true, provider: 'demo' }, { cacheKey });
}, fallback, { cacheKey });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Degraded responses are cached for 10 minutes and written to localStorage (persistCache: true) because no shouldCache guard is provided. If a user's session receives missing_credentials or no_results, that response is stored and replayed from the circuit-breaker cache for the next 10 minutes — even after the server token is configured. Every other breaker in this file that guards against caching empty/degraded results (e.g. breakerDelays uses shouldCache: (r) => r.length > 0) applies this guard; breakerPrices should do the same.

Suggested change
}, fallback, { cacheKey });
}, fallback, { cacheKey, shouldCache: (r) => !r.degraded && r.quotes.length > 0 });

Comment thread src/components/AviationCommandBar.ts Outdated
Comment on lines +235 to +237
} else if (error === 'no_results') {
msg = `No live prices found for ${escapeHtml(intent.origin)} → ${escapeHtml(intent.destination)}.`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The no_results branch doesn't include a Google Flights fallback link, unlike missing_credentials and upstream_error. A user who lands here (valid token, route genuinely has no Travelpayouts coverage) has no obvious next step — the other two error states both offer "Search Google Flights instead →" which would be equally useful here.

Suggested change
} else if (error === 'no_results') {
msg = `No live prices found for ${escapeHtml(intent.origin)}${escapeHtml(intent.destination)}.`;
}
} else if (error === 'no_results') {
msg = `No live prices found for ${escapeHtml(intent.origin)}${escapeHtml(intent.destination)}. <a href="${gflLink}" target="_blank" rel="noopener" style="color:var(--accent,#60a5fa)">Search Google Flights instead →</a>`;
}

PR #3795 self-review surfaced 9 findings; this commit addresses all
of them.

P1 — fail-closed responses were being pinned in the persistent cache:
  - src/services/aviation/index.ts: breakerPrices.execute now passes
    shouldCache: (r) => r.quotes.length > 0 && !r.degraded.
    Without this, a 10-min IndexedDB entry held by `persistCache:true`
    would keep showing "credentials required" to users even after the
    operator restored TRAVELPAYOUTS_API_TOKEN. The breaker also evicts
    existing entries that fail the predicate on the next call, so the
    fix is forward AND retroactive. Matches the pattern in the cluster
    recipe redis-cache-staleness-gotchas/.../circuit-breaker-persist-cache-eviction.

P2:
  - AviationCommandBar.ts: unknown future `error` discriminators now get
    a generic-but-honest "Flight pricing unavailable (X)" message with
    the Google Flights fallback link, instead of the misleading silent
    "No prices found." default. The dead `degraded:false + empty quotes`
    branch is folded into a proper origin→destination wording.
  - tests/search-flight-prices-fail-closed.test.mts: adds a service-layer
    regression-test suite that source-greps the breaker fallback object
    (asserting it is empty + degraded + error:'upstream_error' + never
    demo) AND asserts the new shouldCache predicate is present on the
    breaker call. Pattern: source-grep regression for unexercisable
    defensive branches (couldn't run the service module directly because
    of the test-import-vite-env-dev-transitive trap — both patterns
    documented in test-ci-gotchas).

P3:
  - server/.../search-flight-prices.ts: tightened emptyDegraded(provider)
    from `string` to `'none' | 'travelpayouts_data'` union; extracted
    DegradedError + DegradedProvider type aliases. Added TODO comment
    on the no_results branch noting the provider follow-up (teach
    travelpayouts_data.ts to distinguish fetch-error vs empty-result so
    the proto's `upstream_error` value becomes reachable through normal
    network failures, not just synchronous handler crashes).
  - AviationCommandBar.ts: extracted gflFallbackLink constant — removes
    the duplicate <a href="..."> markup across 3 branches.
  - tests/...: added isIndicative assertions to each scenario as a
    contract guard (was P3/Q in the review).
  - README: split the long sentence about TRAVELPAYOUTS_API_TOKEN into
    two readable paragraphs.

All 6 tests pass; typecheck clean.
@koala73

koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner Author

Self-review fixes landed in commit feb7f79:

P1 — `src/services/aviation/index.ts` breakerPrices.execute now passes `shouldCache: (r) => r.quotes.length > 0 && !r.degraded`. Without this, the breaker's `persistCache:true` would pin a degraded "credentials required" response in IndexedDB for the 10-min TTL even after the operator restored the token. The predicate also evicts existing cached entries that fail it on the next call, so the fix is forward AND retroactive.

P2:

  • Unknown future `error` values in the UI now get `Flight pricing unavailable (X). Search Google Flights instead →` instead of the misleading silent "No prices found." default.
  • Added a service-layer source-grep regression test asserting the breaker fallback shape (`empty + degraded + error:'upstream_error' + never demo`) and that the new `shouldCache` predicate is wired up. Couldn't run the service module in node:test due to the `test-import-vite-env-dev-transitive` trap (`import.meta.env.DEV` access in transitive proxy module); source-grep pattern matches the test-ci-gotchas recipe I extracted earlier.

P3:

  • Tightened `emptyDegraded(provider)` from `string` to `'none' | 'travelpayouts_data'` union.
  • Extracted `gflFallbackLink` constant — removed the duplicated `` markup across 3 UI branches.
  • Split the long README sentence into two paragraphs.
  • Added `isIndicative` assertions to every test case as a contract guard.
  • Added a TODO on the `no_results` branch noting the follow-up: teach `travelpayouts_data.ts` to distinguish fetch-error from empty-result so the proto's `upstream_error` value becomes reachable through normal network failures.

All 6 tests pass; typecheck clean. CI re-running.

koala73 added 2 commits May 18, 2026 12:03
…ailed (#3795 review-2)

Two findings from the second review pass on PR #3795:

P1 — Stale cached flight prices kept masking fail-closed degraded states
=======================================================================

The first review-pass fix added shouldCache to breakerPrices.execute,
which correctly prevents NEW degraded responses from being cached.
But the circuit breaker's stale-while-revalidate refresh path only
skipped the write — it never evicted the existing stale entry. So a
user who previously cached real quotes kept seeing them indefinitely
even after the server started returning missing_credentials /
upstream_error / no_results:

  Call N-1: cache holds real quotes (shouldCache passes on read)
  TTL passes
  Call N:   stale 'real' served (SWR branch); bg refresh fetches
            degraded response; shouldCache fails; skip write.
  Call N+1: stale 'real' STILL in cache; SWR cycle repeats forever.

Fix in src/utils/circuit-breaker.ts: when the SWR refresh returns a
result that fails shouldCache, EVICT the existing cache entry
(in-memory + persistent). Next call sees no cache → falls through to
the live path → surfaces the degraded shape to the UI.

Generalises beyond flight-prices: every breaker caller that pairs
`shouldCache` with `persistCache:true` + SWR had the same latent bug.
Unit test in tests/circuit-breaker-swr-eviction.test.mts.

P2 — Travelpayouts transient failures cached as no_results for 1-2h
===================================================================

The fetcher wrapper used `.then(d => d ?? [])`, converting fetchTp's
null (upstream failure) into [] before cachedFetchJson saw it. This
defeated the NEG_SENTINEL short-TTL protection: cachedFetchJson
treated [] as a valid result and wrote it to Redis with the normal
3600-7200s TTL. The handler then mapped 0 quotes to error:'no_results',
so a transient outage looked like a route-level absence for 1-2 hours
and the UI couldn't distinguish "provider down" from "no fares here."

Fix in server/worldmonitor/aviation/v1/_providers/travelpayouts_data.ts:
  - Stop swallowing null in the cachedFetchJson fetcher. Let null
    propagate so cachedFetchJson caches NEG_SENTINEL for the default
    2-minute negative TTL.
  - Track `upstreamFailed: boolean` across the v3 / month-matrix /
    latest branches and add it to the TravelpayoutsResult interface.

Fix in server/worldmonitor/aviation/v1/search-flight-prices.ts:
  - Use upstreamFailed to pick error:'upstream_error' vs error:'no_results'
    on the empty-quotes path.
  - Remove the now-obsolete TODO + comment claiming `upstream_error`
    was reserved for future work; it's reachable now through normal
    HTTP/network failures.

Tests in tests/search-flight-prices-fail-closed.test.mts:
  - Renamed and clarified the no_results test to stub HTTP 200 + empty
    data (genuine no-fares-on-this-route).
  - Added an explicit HTTP 500 test asserting error:'upstream_error'.
  - Same split applied to the demo opt-in suite.

All 9 tests pass; typecheck clean.
…ket-quote behaviour)

Pre-push test surfaced a semantic conflict from the previous commit:
tests/market-quote-cache-keying.test.mjs has an explicit assertion
("SWR background refresh respects shouldCache predicate") that the
existing stale entry MUST be preserved when a SWR refresh fails
shouldCache — protects against transient upstream blips wiping
previously-good cached quotes. My previous always-evict change broke
that contract.

Fix: make eviction opt-in via a new `evictOnRefreshFailure?: boolean`
option on `breaker.execute(...)`. Default false → preserves the
existing protect-good-data behaviour for market quotes and every
other caller. Opt-in true for flight-prices and any other surface
where the degraded state is itself the important signal users need
to see (fail-closed contract).

Updated src/services/aviation/index.ts fetchFlightPrices to pass
`evictOnRefreshFailure: true`.

Tests:
- tests/circuit-breaker-swr-eviction.test.mts now has two cases:
  the opt-in eviction (was the only test before), AND a new test
  verifying that default behaviour preserves stale entries — same
  intent as the market-quote test, expressed against a synthetic
  breaker.
- tests/search-flight-prices-fail-closed.test.mts adds an assertion
  that the breaker call includes `evictOnRefreshFailure: true`.

All 28 affected tests pass (10 in market-quote, 9 in flight-prices,
2 in circuit-breaker-swr, plus the rest); typecheck clean.
@koala73

koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner Author

Pushed two commits addressing both review findings:

Commit 0461053 — initial review-2 fixes:

  • P1 (SWR eviction): `src/utils/circuit-breaker.ts` — when a stale-while-revalidate refresh returns a result that fails `shouldCache`, EVICT the existing stale cache entry (memory + persistent) so the next call falls through to the live path and surfaces the degraded shape. Without this, SWR pinned the stale entry indefinitely.
  • P2 (Travelpayouts upstreamFailed): `server/.../travelpayouts_data.ts` stops swallowing fetchTp's null inside the cachedFetchJson fetcher. Null now propagates so cachedFetchJson caches NEG_SENTINEL for 2 min (vs. the previous bug where transient failures became empty arrays cached for 1-2h). New `upstreamFailed: boolean` field on TravelpayoutsResult lets the handler distinguish real upstream errors from genuine no-results.
  • Handler updated to emit `error: 'upstream_error'` when `upstreamFailed` and `error: 'no_results'` otherwise. Removed the obsolete TODO.
  • Tests cover HTTP 500 → upstream_error AND 200+empty → no_results, default-off and demo-on.

Commit 88972a6 — pre-push test caught a semantic conflict:
`tests/market-quote-cache-keying.test.mjs` explicitly asserts the OLD "preserve stale on refresh-failure" behaviour to protect against transient blips wiping good cached quotes. My always-evict change broke that contract.

Fix: made eviction opt-in via a new `evictOnRefreshFailure?: boolean` option on `breaker.execute(...)` (default false). Flight-prices opts in (`evictOnRefreshFailure: true`); market quotes and every other caller keep the preserve-stale behaviour they currently depend on.

Tests now cover both modes:

  • Opt-in eviction (flight-prices)
  • Default preserve (market-quote contract — exists as both the original test and a synthetic dup in the breaker test file as a contract guard)

Final test count: 28/28 pass across the 3 affected files; typecheck clean.

@koala73
koala73 merged commit f394aa9 into main May 18, 2026
13 checks passed
@koala73
koala73 deleted the fix/3756-flight-prices-fail-closed branch May 18, 2026 11:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

README says basic operation needs no env vars, but flight-price search returns randomized demo quotes without provider credentials

1 participant