Status: draft for review · Date: 2026-09-07 · Scope: v1.0
A production-ready Python Discord bot that tracks daily one-way flight prices from Algiers (ALG) to Tokyo Narita (NRT), Tokyo Haneda (HND), Osaka (KIX), Seoul Incheon (ICN), and Busan (PUS), stores them in SQLite, maintains a 30-day rolling average per route, and sends a Discord Embed alert when today's lowest fare drops ≥ 20% below that average.
Decisions locked with the owner: one-way fares · window + daily-low strategy (each day, sample the cheapest departure within the next 30 days) · EUR · Google Flights 2 as default provider (Sky Scrapper adapter included).
Two research agents verified both RapidAPI providers against their live hub documentation, pricing pages, and ~10 independent consumer codebases (GitHub wrappers, MCP trackers, clones). Three independent architecture proposals (production-reliability, minimal-complexity, and data-correctness lenses) were then synthesized into this plan. Raw research and design artifacts are kept in .research_gf2/ (gitignored) as implementation reference.
The most important finding: the endpoint names you'd guess for both providers don't exist.
| Fact | Detail |
|---|---|
| Search endpoint | GET /api/v1/searchFlights — one-way and round-trip share it (omit return_date for one-way). There is no searchFlightsOneWay / searchFlightsRoundTrip / searchEverywhereQuest, no rowDataIndex/hl/gl params. |
| Daily-low workhorse | GET /api/v1/getCalendarPicker — one call per route returns the cheapest fare per departure date for the whole window (up to ~60 days): data[] = [{departure: "YYYY-MM-DD", price: <int>}, ...]. This is exactly the daily-low series we need, at 5 requests per run instead of 5×N. |
| Fallback | GET /api/v1/getPriceGraph — identical data[] shape; used when the calendar window comes back empty. |
| Envelope | {"status": true/false, "message": ..., "timestamp": <epoch-ms>, "data": ...} — clean JSON. status:false inside HTTP 200 = provider-side scrape failure. |
| Prices | Integers in the requested currency (whole euros when currency=EUR). No decimals, no protobuf units/nanos. |
| Params | departure_id, arrival_id (IATA), outbound_date, start_date, end_date (all YYYY-MM-DD), trip_type=ONE_WAY, currency, country_code (market context; DZ = Algeria), adults='1' (string), travel_class=ECONOMY. Docs conflict on whether outbound_date is required for the calendar endpoint — we pass it always. |
| Booking links | No URL on flight items. A bookable link is a 3-request chain: searchFlights → per-item booking_token → getBookingDetails?booking_token=... (partner list) → getBookingURL?token=... (plain URL string). ~2–3 billed requests per link. |
| Pagination | Token-based (next_token), not index-based. We never need it (calendar calls). |
| Quota | BASIC 150 req/month HARD limit; PRO $12.99 = 40,000/mo. Practical throttle ~250/min. Every response carries x-ratelimit-requests-remaining etc. |
| Error modes (verified live) | 403 {"message":"You are not subscribed to this API."} (valid key, no subscription — the most common day-one failure); 429 = per-minute throttle or monthly hard-limit exhaustion or missing key header (all three present as 429 — disambiguate via Retry-After + x-ratelimit-requests-remaining); 5xx = upstream scrape faults; HTTP 200 with empty data = legitimate "no inventory" (expected frequently for ALG→PUS), not an error. |
| Latency | Live aggregation: ~6.4 s average, up to 10 s cold. Client timeout must be ≥ 20 s. |
| Fact | Detail |
|---|---|
No /flights/search-one-way — one-way is searchFlights without returnDate. |
|
| Daily-low workhorse | GET /api/v1/flights/getPriceCalendar with originSkyId, destinationSkyId, fromDate. SkyIds are plain IATA codes for these airports — the entityId two-step lookup is not needed on the calendar path. Response: data.flights.days[] = [{day, group, price(float)}] (defensive fallback path: data.days[]). |
| Prices | itineraries[].price.raw — already a plain float. No price_raw.units/nanos, no deep links anywhere in this API (booking links are a Google-Flights-2-only feature; Sky Scrapper alerts fall back to a free web-search URL). |
| Quota | Free tier = 20 req/month HARD (≈ 4 runs); PRO $9.99 = 10,600/mo. |
| EUR | Supported (currency=EUR). |
We always have a zero-cost fallback: a hand-built one-way Google Flights search URL (https://www.google.com/travel/flights?q=Flights%20from%20ALG%20to%20NRT%20on%20YYYY-MM-DD%20one%20way) — origin, destination, date, and the one-way trip type are all encoded (Google Flights otherwise defaults to the viewer's market trip type, usually round-trip, showing prices that differ from the alert's one-way fare) — so every alert carries some actionable link.
All RapidAPI headers are parameterized: RAPIDAPI_KEY → X-RapidAPI-Key, RAPIDAPI_HOST → X-RapidAPI-Host, set once on a shared requests.Session.
┌────────────────────── 09:15 Africa/Algiers, daily ──────────────────────┐
│ bot.py @tasks.loop daily_check │
│ └─ asyncio.to_thread(tracker.run_cycle('scheduled')) │
│ ├─ stat_date D = today's Algiers date (computed ONCE per run) │
│ ├─ pre-run gates: quota check (meta), orphan-run cleanup │
│ ├─ per active route (isolated try/except, 2 s pacing): │
│ │ getCalendarPicker → validate each quote │
│ │ (ISO date, numeric, >0, floor≤price≤ceiling, in window) │
│ │ → fare_quotes (RAW layer: every quote, valid or not) │
│ │ → low = min(valid in-window) → route_day_stats (data point) │
│ │ → empty window? retry once via getPriceGraph (if enabled) │
│ ├─ evaluate each route (database.get_baseline_series → │
│ │ warmup guard → outlier band → trimmed mean → ≥20% test → │
│ │ cooldown → confirmation re-fetch) → alerts row (pending) │
│ └─ system notices (quota / auth / outage / stale / dead routes) │
└──────────────────────────────────────────────────────────────────────────┘
│
┌────── delivery loop @tasks.loop(minutes=1) ──────────────────────────────┐
│ write heartbeat → meta │ drain pending alerts: resolve booking link │
│ (if mode=resolve + quota headroom) → send Embed → mark sent │ │
│ retry ≤10 with backoff │ drain pending system_alerts → admin channel │
└──────────────────────────────────────────────────────────────────────────┘
Design posture (synthesized from three independent designs):
- Simplicity spine — exactly the specified file set (
database.py,tracker.py,bot.py) plus one ~20-linehealthcheck.pyfor Docker. No framework, no ORM, no cogs, no adapter classes — the two providers are two plain functions behind one signature, picked once fromAPI_PROVIDER. - Reliability machinery — evaluation is decoupled from delivery (at-least-once send with DB truth, ≤10 retries); a 1-minute heartbeat loop drives the Docker healthcheck and delivery; every API call is classified (retryable / throttle / fatal-auth / quota-exhausted) with bounded retries and per-route isolation; one SQLite writer under WAL with short per-route transactions.
- Data-correctness core — the data point is a lead-time-stationary market index, not a raw quote; a full raw-quote layer makes every alert recomputable; four stacked defenses (quote band → series outlier band → trimmed mean → confirmation re-fetch) stand between a glitched quote and a false alert; a
params_hashmethodology fingerprint prevents phantom drops after any config change.
| File | Purpose |
|---|---|
requirements.txt |
discord.py>=2.4,<3 · requests>=2.32,<3 · python-dotenv>=1.0,<2 · tzdata>=2024.1 (mandatory: python:3.11-slim ships no system zoneinfo — without it ZoneInfo("Africa/Algiers") crashes at startup). Everything else is stdlib (sqlite3, zoneinfo, statistics, hashlib, json, logging, asyncio). |
.env.example |
Every knob, grouped + commented (full contents in §11). |
database.py |
The only module that touches sqlite3. Connection pragmas, idempotent DDL + route seeding, and typed helpers — including get_baseline_series() implementing the 30-day rolling average. connect() mkdirs the DB's parent directory (via os.path.abspath) before the first connection so a bad path fails loudly at startup. No Discord imports. |
tracker.py |
RapidAPI access + the alert engine. Both provider adapters (fetch_daily_lows(origin, destination, start_date, end_date, ...) -> dict[date, price]), the shared HTTP client with the full error taxonomy, quote validation, run_cycle(), evaluate_route(), resolve_booking_link(). No Discord imports. Runnable standalone (python tracker.py) to smoke-test API + DB without Discord. |
bot.py |
Discord surface only: client (no privileged intents — slash commands don't need Message Content Intent), the two @tasks.loops, /checkflights + /status slash commands, Embed rendering, graceful shutdown. Validates all env config fail-fast at startup and passes explicit arguments into database/tracker (no shared config module needed). |
healthcheck.py |
Docker HEALTHCHECK: reads meta.heartbeat_utc read-only; exit 0 if younger than 10 minutes. |
Dockerfile |
python:3.11-slim-bookworm, non-root, tzdata, layered caching. |
docker-compose.yml |
Single service, named volume, healthcheck, log rotation. |
.dockerignore / .gitignore |
Keep .env, *.db*, .git, .research_gf2/, litellm_config.yaml, __pycache__/ out of image and VCS. |
plan.md |
This document. |
All calendar dates (run_date, stat_date, departure_date, alert_date) are YYYY-MM-DD strings computed in Africa/Algiers — they sort lexicographically and are immune to clock/DST games. Machine timestamps (*_utc) are ISO-8601 UTC. SQLite has no leap seconds, and Algeria has no DST, so date-keyed logic is stable by construction.
-- Connection pragmas applied on EVERY open (in database.py):
PRAGMA journal_mode = WAL; -- crash-safe; readers never block the writer
PRAGMA synchronous = NORMAL; -- survives process crash; host power loss may lose last checkpoint (accepted)
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
-- Key/value state: schema_version, heartbeat_utc, quota counters, stale-streak scratch
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Route registry; seeded idempotently from TRACKED_ROUTES (INSERT OR IGNORE)
CREATE TABLE IF NOT EXISTS routes (
route_id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL CHECK (length(origin) = 3 AND origin = upper(origin)),
destination TEXT NOT NULL CHECK (length(destination) = 3 AND destination = upper(destination)),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
created_utc TEXT NOT NULL,
UNIQUE (origin, destination)
);
-- One row per execution of the cycle
CREATE TABLE IF NOT EXISTS runs (
run_id INTEGER PRIMARY KEY AUTOINCREMENT,
run_date TEXT NOT NULL CHECK (run_date = date(run_date)),
trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('scheduled','catchup','manual')),
started_utc TEXT NOT NULL,
finished_utc TEXT,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running','completed','partial','skipped',
'quota_exhausted','auth_error','interrupted','failed')),
routes_ok INTEGER NOT NULL DEFAULT 0,
routes_failed INTEGER NOT NULL DEFAULT 0,
params_hash TEXT NOT NULL, -- methodology fingerprint (see §7)
params_json TEXT NOT NULL, -- full param set of this run, for audit
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_runs_date ON runs (run_date, run_id);
-- RAW LAYER: every quote ever returned, valid or not. Nothing is ever overwritten.
-- This is the spec's quote table, columns normalized. Spec -> schema map:
-- route -> route_id + routes(origin, destination) (normalized route identity)
-- price -> price_eur (one price column; currency is CHECKed to 'EUR')
-- currency -> currency (unchanged) · departure_date -> departure_date (unchanged)
-- fetched_at -> fetched_utc (machine timestamps are UTC, per the §5 header note)
-- booking_link -> alerts.booking_url (one per SENT alert, not per quote — the link
-- is resolved per sent alert's winning route+date at delivery time (§8/§9);
-- the triggering quote is identifiable via route_id + stat_date +
-- departure_date = alerts.best_departure_date + fetch_kind='canonical')
CREATE TABLE IF NOT EXISTS fare_quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(run_id),
route_id INTEGER NOT NULL REFERENCES routes(route_id),
stat_date TEXT NOT NULL CHECK (stat_date = date(stat_date)),
departure_date TEXT NOT NULL CHECK (departure_date = date(departure_date)),
price_eur REAL CHECK (price_eur IS NULL OR price_eur > 0),
currency TEXT NOT NULL DEFAULT 'EUR' CHECK (currency = 'EUR'),
fetch_kind TEXT NOT NULL CHECK (fetch_kind IN ('canonical','confirmation','manual')),
source TEXT NOT NULL CHECK (source IN ('calendar','price_graph')),
is_valid INTEGER NOT NULL DEFAULT 0 CHECK (is_valid IN (0, 1)),
invalid_reason TEXT, -- 'below_floor','above_ceiling','bad_date','non_numeric','out_of_window'
fetched_utc TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotes_route ON fare_quotes (route_id, stat_date, fetch_kind);
-- THE DATA POINT: one row per (route, Algiers day). Holds the fetch outcome and the
-- full evaluation audit, so any third party can recompute an alert's numbers.
CREATE TABLE IF NOT EXISTS route_day_stats (
route_id INTEGER NOT NULL REFERENCES routes(route_id),
stat_date TEXT NOT NULL CHECK (stat_date = date(stat_date)),
params_hash TEXT NOT NULL,
window_start TEXT NOT NULL, -- D + LEAD_DAYS
window_end TEXT NOT NULL, -- window_start + WINDOW_DAYS - 1
quotes_in_window INTEGER NOT NULL DEFAULT 0,
raw_min_eur REAL, -- pre-validation min (audit only)
daily_low_eur REAL CHECK (daily_low_eur IS NULL OR daily_low_eur > 0), -- NULL = no data (NEVER 0)
best_departure_date TEXT,
fetch_time_kind TEXT NOT NULL CHECK (fetch_time_kind IN ('scheduled','off_schedule')),
fetch_outcome TEXT NOT NULL CHECK (fetch_outcome IN
('ok','empty','http_error','provider_error','invalid',
'throttled','quota_exhausted','skipped')),
fetch_error TEXT,
-- evaluation audit (written by the once-daily evaluation pass):
baseline_eur REAL,
plain_mean_eur REAL,
pct_drop REAL,
n_prior_days INTEGER,
n_outliers_excluded INTEGER,
n_trimmed_per_tail INTEGER,
eval_outcome TEXT CHECK (eval_outcome IN
('no_alert','alert_sent','suppressed_warmup','suppressed_cooldown',
'suppressed_no_confirm','suppressed_methodology_change',
'no_data_today','error')),
stale INTEGER NOT NULL DEFAULT 0,
evaluated_utc TEXT,
PRIMARY KEY (route_id, stat_date)
);
CREATE INDEX IF NOT EXISTS idx_stats_series
ON route_day_stats (route_id, stat_date) WHERE daily_low_eur IS NOT NULL;
-- Sent-alert delivery state; UNIQUE(route, day) = hard same-day dedup
CREATE TABLE IF NOT EXISTS alerts (
alert_id INTEGER PRIMARY KEY AUTOINCREMENT,
route_id INTEGER NOT NULL REFERENCES routes(route_id),
stat_date TEXT NOT NULL CHECK (stat_date = date(stat_date)),
low_eur REAL NOT NULL CHECK (low_eur > 0),
best_departure_date TEXT,
baseline_eur REAL NOT NULL CHECK (baseline_eur > 0),
pct_drop REAL NOT NULL,
confirmed_low_eur REAL, -- the confirmation re-fetch reading
stale INTEGER NOT NULL DEFAULT 0,
booking_url TEXT,
booking_kind TEXT CHECK (booking_kind IN ('resolved','static','none')),
discord_message_id TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
created_utc TEXT NOT NULL,
sent_utc TEXT,
UNIQUE (route_id, stat_date)
);
CREATE INDEX IF NOT EXISTS idx_alerts_pending ON alerts (status) WHERE status = 'pending';
-- Ops notices (auth/quota/outage/stale/failed sends/db corruption), deduped by (kind, day)
CREATE TABLE IF NOT EXISTS system_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('auth_error','quota_low','quota_exhausted',
'provider_outage','route_no_data','stale_data','discord_send_failed',
'db_corrupt')),
alert_date TEXT NOT NULL CHECK (alert_date = date(alert_date)),
detail TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')),
created_utc TEXT NOT NULL,
UNIQUE (kind, alert_date)
);Table-by-table rationale (7 tables, each earns its place):
meta— schema version, the heartbeat the Docker healthcheck reads, monthly quota counters (local count cross-checked against provider headers).routes— normalized route identity;is_activelets you disable a route without losing history.runs— audit trail: what ran, when, triggered how, with which exact methodology (params_json).fare_quotes— the raw layer. Storing every quote (even validation failures, flagged withinvalid_reason) makes every alert recomputable from first principles; the spec'sbooking_linklives one table over, onalerts.booking_url, filled at resolve time only for sent alerts (see the spec column map in the DDL comment above).route_day_stats— the canonical data point + fetch outcome + evaluation audit in one row: "why didn't I get an alert today?" is always answerable.alerts— delivery state machine (pending → sent/failed), same-day dedup viaUNIQUE(route_id, stat_date), booking link (the spec'sbooking_link, §5) + Discord message id.system_alerts— ops notices with a hard cap of one per kind per day (no 3 a.m. spam when the provider is down).
Volume is trivial for SQLite (~110k quote rows/year; sub-millisecond queries). Single-writer discipline: exactly one connection, check_same_thread=False, guarded by a lock; every write is a short explicit transaction, never held open across a network call; one process only — never --scale this service (a second writer on the same volume is the one supported way to corrupt the DB; WAL + busy_timeout only make local smoke-tests safe).
Canonical-supersede rule for route_day_stats upserts (makes every re-run idempotent):
- No existing row for (route, day) → insert.
- Existing row has NULL
daily_low_eurand the incoming fetch produced a low → replace (a failed morning fetch doesn't permanently kill the day's data point). - Existing row is
off_schedule(e.g. a catchup/manual run filled the day) and the incoming fetch isscheduled→ replace (the consistent-hour reading wins). - Otherwise (a scheduled/canonical row exists) → first-canonical-wins: the stat is left untouched; the new fetch lands only in the raw layer.
A data point is not one quote. It is the route-day market index:
low(route, D) = min EUR fare across all VALID quotes for departures
within the sliding window W(D) = [D+LEAD_DAYS, D+LEAD_DAYS+WINDOW_DAYS-1]
defaults: LEAD_DAYS=1, WINDOW_DAYS=30 → [D+1, D+30]
- Lead-time stationarity: the window always spans exactly departure lead times 1..30. Each day, the lead-1 date that fell out is replaced by a fresh lead-30 date — the index always measures "cheapest departure within the next month," with no composition drift, and departed flights can never enter the window.
- One canonical observation per route per Algiers calendar day. The daily run fires at
RUN_TIME_LOCAL(default 09:15,Africa/Algiers): far from midnight (no date-straddling), a consistent sampling hour (removes intra-day price-cycle bias), and the window is unambiguously future-dated in every timezone. - Explicit dates always — we send
start_date/end_dateon every call and never rely on the API's own "today" default, whose timezone we don't control. - No backfill, ever. Fares are live-scraped and stale within minutes; a missed day is a gap (NULL), never interpolated, never filled from "price history" endpoints (those return today's quotes, not history). Gaps simply shrink the baseline sample; the min-history guard absorbs them. A missing day biases the low upward (min over a subset), i.e. against false alerts — the safe direction.
- Empty is data. ALG→PUS legitimately returns an empty calendar on many dates (Google surfaces little inventory for that O&D). That's
fetch_outcome='empty', a NULL daily low, zero retries — never an error, never 0. params_hash= sha256 over{provider, currency, country_code, travel_class, lead_days, window_days, adults}. Stored on every run and every stat row. Baselines are computed only over same-fingerprint days — change any methodology knob and the series re-primes (suppressed_methodology_changeduring the new warmup) instead of manufacturing phantom drops by comparing incomparable data.
Per route, per stat_date D, evaluated once daily from the canonical stat row. All comparisons at full precision; the threshold test is strict (>= 20.0; 19.999 does not alert). Defaults in §11 are assumed.
Tier 1 — quote validation (applied to every (departure_date, price) the adapter returns, before any min): departure strict-parses as ISO YYYY-MM-DD (never guessed); price numeric, finite, > 0; FARE_FLOOR_EUR <= price <= FARE_CEILING_EUR (80–12,000 EUR: nothing real flies ALG→East-Asia under ~80 EUR, and the ceiling instantly quarantines DZD-scale contamination, ~146× EUR). Invalid quotes are persisted with is_valid=0 + reason, never enter the min. Derive daily_low_eur = min over valid in-window quotes; best_departure_date = earliest date achieving the min (deterministic tie-break). Record raw_min_eur (pre-validation, audit) and quotes_in_window.
Tier 2 — series outlier exclusion. Baseline input H = daily_low_eur from route_day_stats for stat_date ∈ [D-30, D-1], daily_low_eur NOT NULL, same params_hash. Let n = |H|.
Guards and evaluation, in order (short-circuit, every outcome recorded in eval_outcome):
daily_low_eur IS NULL→no_data_today. Done.n < MIN_HISTORY_DAYS(7) →suppressed_warmup./statusshows "building baseline (k/7 days)". Counts distinct prior stat dates, not fetches.- Median band:
m = median(H); exclude values outside[m / SERIES_OUTLIER_FACTOR, m × SERIES_OUTLIER_FACTOR](3.0, symmetric so the estimator stays unbiased). Exclusions counted and logged. If remainingn' < 7→suppressed_warmup. - 30-day rolling average (robust):
k = floor(n' × BASELINE_TRIM_PCT / 100)(10% ⇒ k=0 below 10 days, 1 for 10–19, 2 for 20–29, 3 at n=30). Sort ascending, drop the k lowest and k highest;baseline = meanof the middle. The plain (untrimmed) mean is computed and stored for audit but is deliberately not a second trigger — an AND-check on it would suppress real drops exactly when low-side glitches deflate the raw mean, which is when the trimmed statistic is the more correct one. This is the spec's "30-day rolling average," hardened: a plain mean is one glitched 9,999-EUR day away from poisoning every alert for a month; a median isn't an average; a 10%-trimmed mean is both. pct_drop = (baseline - low) / baseline × 100. If< DROP_THRESHOLD_PCT→no_alert.- Cooldown: if an alert was sent for this route within
ALERT_COOLDOWN_DAYS(7 Algiers days) andpct_drop < last_sent_pct_drop + RE_ALERT_DEEPEN_PCT(5 pp) →suppressed_cooldown. A materially deeper collapse re-alerts immediately; a flat cheap regime does not spam. - Tier 4 — confirmation re-fetch (
CONFIRM_BEFORE_ALERT=1): one fresh calendar fetch for the route (quotes stored withfetch_kind='confirmation'; they never replace the canonical point). Fire only if the confirmed low also satisfies ≥ 20% against the same baseline. Confirmation failing/erroring →suppressed_no_confirm(if the drop is real it re-fires on the next day's run naturally). Cost: +1 request per alert candidate — rare, budgeted. - Candidate survives →
INSERT INTO alerts ... ON CONFLICT DO NOTHING(same-day crash-safe dedup; if a row already exists, skip) → statuspending;eval_outcome='alert_sent'reserved for after delivery confirms (the evaluation row meanwhile carries the decision). - Audit row: every route-day gets its numbers —
baseline_eur,plain_mean_eur,pct_drop,n_prior_days,n_outliers_excluded,n_trimmed_per_tail,eval_outcome,evaluated_utc— so "why no alert?" and "was this alert right?" are always answerable from the DB alone.
Stale-data tagging: if a route's full price vector is identical for STALE_STREAK_RUNS (3) consecutive runs, that day's stat is tagged stale=1; an alert on a stale day carries a "(possibly stale upstream data)" caveat and a stale_data system notice fires (deduped). If all five routes come back empty/failed in one run → provider_outage notice (don't let a dead provider look like "no deals"). A route with 3+ consecutive NULL stat days → route_no_data notice.
First-week behavior, explicitly: days 1–7 — no alerts possible, /status shows warmup progress; days 8–30 — alerts possible off the available history (trimming engages at n≥10; below that tier-1 band validation — always on — is the primary glitch filter); day 31+ — full 30-day window modulo gaps.
Two loops, both @tasks.loop (the required scheduling mechanism), both started once in setup_hook, both with their entire tick body wrapped in try/except Exception (a raising tasks loop cancels itself — these loops are immortal):
daily_check—@tasks.loop(time=[datetime.time(9, 15, tzinfo=ZoneInfo("Africa/Algiers"))])(parsed fromRUN_TIME_LOCAL). Tz-aware time works regardless of the container's UTC clock. Tick: guardis_ready()→asyncio.to_thread(tracker.run_cycle, 'scheduled')(the whole sync cycle runs in a worker thread — 5 calls × up to 20 s would otherwise block the event loop ~40+ s and drop the Discord gateway).delivery_loop—@tasks.loop(minutes=1). Tick:asyncio.to_thread(delivery_tick)— heartbeat write tometa(this is what the Docker healthcheck reads — process liveness, deliberately not data freshness: an API outage must not trigger a restart loop; it surfaces via the admin channel), pending-alerts drain, and pending system-notice drain all run in a worker thread; only the Embed sends (await channel.send(...)) stay on the event loop, per alert as resolve (thread) → send (loop) →mark_sent+discord_message_id(thread). Same rationale asdaily_check: in the defaultBOOKING_LINK_MODE=resolve, §9's chain is 3 sequential blockingrequestscalls (× up to 20 s × retries, plus the 429 branch's ≤60 s wait) per alert — and one market-wide shock can leave all five routes pending in a single tick. On the event loop that blocks it for tens to hundreds of seconds, dropping the Discord gateway heartbeat, stalling the slash commands, and starving thedaily_checktick; the wrap-everythingtry/exceptkeeps the loop alive but cannot unblock it.
Exactly-once semantics are DB properties, not memory assumptions:
- The stat row PK
(route_id, stat_date)+ the canonical-supersede rule (§5) make any re-run — crash, restart, manual — idempotent. - The alerts
UNIQUE(route_id, stat_date)+ON CONFLICT DO NOTHINGmake a same-day double-alert impossible even across a crash-and-retry. - Run serialization (concurrent triggers): the three
run_cycleentry points — thedaily_checktick, startup catch-up, and/checkflights— each run on their own worker thread (to_thread), and the idempotence above is about sequential re-runs: DB PKs dedupe rows but cannot stop two concurrent cycles spending quota against each other. A module-levelthreading.Lockintracker.pyguardsrun_cycle, acquired non-blocking at entry. If held:scheduled/catchupreturn immediately without writing arunsrow;manualreturns at once and/checkflightsreplies "a run is already in progress — try again in a minute". Lock order is always run-lock → §5 connection lock (never reversed), so no deadlock; the standalonepython tracker.pypath is unaffected. - Catch-up at startup (
setup_hook/on_ready): if today's Algiers stat_date has no stat rows (bot was down at 09:15) and local time is past the slot → run once withtrigger='catchup', markedoff_schedule(per §5 rule 3, a later canonical run would supersede it — none will come today, so it stands). If rows exist → no-op. An orphanedrunsrow stuck inrunningfrom a killed process → markedinterrupted; per-route commits mean completed routes are already durable, and the catch-up rule refetches only routes whose stat row is absent or whosefetch_outcomeis retryable (http_error,provider_error,invalid,throttled) — neverok/emptyrows. - Missed days while the container was down: skipped, no backfill (§6).
Delivery state machine: pending → resolve booking link (see §9) → send Embed → sent + discord_message_id. A send failure increments attempts, retries next tick; after 10 attempts → failed + one discord_send_failed notice. A crash between send and mark_sent can at worst duplicate one identical message once (accepted; at-most-duplicate chosen over at-most-lost).
Graceful shutdown: SIGTERM/SIGINT → stop loops → finish the current short transaction → PRAGMA wal_checkpoint(TRUNCATE) → close connection → bot.close(). Bounded by a 20 s asyncio timeout; compose gives 30 s stop_grace_period before SIGKILL. WAL makes a SIGKILL at any instant a recoverable non-event.
Slash commands (no privileged gateway intents required — slash commands don't need Message Content Intent):
/checkflights(required by the spec) — runs the cycle immediately (trigger='manual', sync viato_thread). Replies with today's fresh prices per route + each route's baseline state. Series-safe by construction: the manual fetch lands in the raw layer, the stat row follows the supersede rule (fills the day only if nothing canonical exists), and alert evaluation reads the stat row — so a manual run can never double-alert or contaminate the series./status— per route: last stat (low, best date, warmup k/7, eval outcome), quota remaining, last run status, pending/failed alerts.
Alert Embed (rich, per spec): title ALG → Tokyo Narita (NRT) — price drop!; fields for today's low (€), the rolling average it's compared against (with n and "10%-trimmed, outliers excluded" footnote), the drop %, cheapest departure date, a booking link (resolved or static fallback), fetched-at Algiers timestamp; green accent for deals (amber + stale caveat when tagged).
Interface — two plain functions behind one signature, selected once at startup from API_PROVIDER (the system's only abstraction, present because provider switchability is a requirement):
def fetch_daily_lows(origin: str, destination: str,
start_date: str, end_date: str,
session: requests.Session, cfg) -> dict[str, float]
# 'YYYY-MM-DD' -> lowest EUR fare the provider quotes for that departure date.
# {} (HTTP 200, status true, no rows) = no inventory -> NULL stat, NOT an error.
# Raises AdapterError (classification in .kind) on hard failure after the retry policy.google-flights2 adapter (default). GET /api/v1/getCalendarPicker with departure_id, arrival_id, outbound_date=start_date (passed despite docs conflict), start_date, end_date, trip_type=ONE_WAY, currency=EUR, country_code, adults='1', travel_class (no language_code — the calendar endpoints don't document it, and the response is only {departure, price}). Parse: require status true; data a list of {departure, price}; per-row validation (§7 tier 1). If the window comes back empty and USE_PRICE_GRAPH_FALLBACK=1 → one retry via GET /api/v1/getPriceGraph (same shape, source='price_graph' recorded). Empty 200s are never retried further.
sky-scrapper adapter. GET /api/v1/flights/getPriceCalendar with originSkyId, destinationSkyId, fromDate, currency=EUR (sent explicitly on every call, mirroring the google-flights2 adapter — the interface contract is EUR-denominated lows; currency is already part of the params_hash, so this is purely additive). SkyIds are plain IATA codes on this path; the entityId two-step is documented as the contingency if the calendar ever comes back empty across all routes, not built preemptively. Parse: data.flights.days[] (fallback data.days[]), day defensive-parsed (first 10 chars / ISO prefix), price plain float; client-side filter to the window.
Shared HTTP client — one requests.Session with X-RapidAPI-Key / X-RapidAPI-Host set once; timeout=20; 2 s pacing between route calls; error classification → action:
| Class | Action |
|---|---|
requests.Timeout / ConnectionError / HTTP 5xx |
Retry up to RETRY_MAX_ATTEMPTS (3) total, exponential backoff + jitter. Exhausted → route recorded with outcome, run continues (a dead route never kills the run). |
| HTTP 429 | Triangulate via Retry-After + x-ratelimit-requests-remaining: throttle → bounded wait (≤60 s), retry ≤2; monthly hard-limit exhaustion → QuotaExhausted aborts the whole run immediately (every further call fails identically; burning retries against a hard limit is pointless) → quota_exhausted status + notice. (Verified trap: a missing key header also presents as 429 — on any 429 we log which headers were attached, key masked to last 4 chars.) |
| HTTP 401 / 403 | Fatal, zero retries (auth is not transient). Abort run, auth_error status + notice carrying the gateway message — 403 "You are not subscribed to this API." means the key isn't subscribed to this API's page. The bot stays up and tries again next day; an operator fixing the key needs no restart. |
HTTP 200 + status:false |
One retry after backoff, then provider_error for the route. |
| Malformed JSON / unexpected shape | One retry, then invalid for the route. Never an exception escaping to the loop. |
| Empty-but-200 window | empty — legitimate, no retry (beyond the optional price-graph fallback). |
Quota accounting — every response (including 429s and 5xx) updates meta counters for the current month — as a single-statement UPDATE meta SET value = CAST(value AS INTEGER) + ? WHERE key = ? (helper in database.py), never a Python read-modify-write, so concurrent billers (the delivery loop's booking chain bills while a cycle runs, by design) cannot lose an increment. Provider headers are persisted when parseable. effective_remaining = min(PLAN_MONTHLY_LIMIT - used, header_remaining) — the min means the provider's own 150-hard-limit header binds even if the operator forgets to edit PLAN_MONTHLY_LIMIT down from the PRO default. Pre-run gate: the run refuses to start when effective_remaining < routes_to_fetch + QUOTA_RESERVE_REQUESTS — it still writes a runs row (status='skipped', routes_ok=0, routes_failed=0) plus a quota_low notice (deduped per day), and writes no stat rows for the day; a later catch-up re-trips the gate the same way. checkServer is not called routinely (every call bills); auth failures surface on the first real call.
Run cost model — the default profile spends ≈ 155–170+ requests/month: 5 calendar calls/day, plus price-graph fallbacks (routine, not occasional — ALG→PUS empties are the norm), +1 confirmation per alert candidate, +3 per resolved booking link. That is a PRO-tier profile; PRO ($12.99, 40k/mo) is the realistic tier and what the §11 defaults assume. Even the bare core cannot finish a BASIC (150/mo hard) month: the pre-run gate needs 11 requests to start, so 150 funds at most ~28 runs — days 29–31 are skipped with a quota_low notice every month (safe per §6: gaps shrink the baseline sample and bias against false alerts). A BASIC subscriber must apply the degradation set (BOOKING_LINK_MODE=static, USE_PRICE_GRAPH_FALLBACK=0, CONFIRM_BEFORE_ALERT=0) and accept the 2–3 tail days. (Sky Scrapper free tier supports ~4 runs/month total — effectively unusable for a daily bot; its adapter is for paid-tier switching.)
Booking links (BOOKING_LINK_MODE): for a sent alert's winning route+date, resolve mode runs the chain — searchFlights (search_type=cheap) → cheapest itinerary's booking_token (if absent: skip straight to fallback, never chase getNextFlights pages) → getBookingDetails → pick partner (lowest price, prefer is_airline) → getBookingURL → plain URL string (accept only http...) — quota-gated (needs headroom ≥ reserve+3, else static fallback). static mode skips the chain. Any failure at any step falls back to a free hand-built one-way Google Flights search URL (§2 template) — the alert always ships with a link. Sky Scrapper has no booking chain at all → static fallback only.
Dockerfile — FROM python:3.11-slim-bookworm (pinned base whose libsqlite3 is 3.40+ — WAL fully supported). ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 TZ=Africa/Algiers (TZ affects log readability only — every correctness path uses ZoneInfo explicitly). Layer order for caching: COPY requirements.txt → pip install --no-cache-dir (installs tzdata — slim images ship no /usr/share/zoneinfo) → COPY *.py. Non-root user (uid 1000) owning /app and /data. No EXPOSE (outbound-only: Discord gateway + RapidAPI). No secrets in any image layer — everything arrives via compose env_file. CMD ["python", "bot.py"] (signal handling in-process; no ENTRYPOINT wrapper needed). .dockerignore: .env, *.db*, .git, .research_gf2/, litellm_config.yaml, __pycache__/, plan.md.
docker-compose.yml — single service flight-bot:
services:
flight-bot:
build: .
container_name: flight-tracker-bot
init: true # proper PID-1: SIGTERM reaches python, zombies reaped
restart: unless-stopped # survives host reboots and transient crashes
stop_grace_period: 30s # matches the 20 s bounded in-process shutdown
env_file: .env
environment:
TZ: Africa/Algiers
DB_PATH: /data/flight_tracker.db # pinned — compose environment overrides env_file
volumes:
- flight-data:/data # NAMED volume — see note below
healthcheck:
test: ["CMD", "python", "healthcheck.py"] # heartbeat age < 10 min
interval: 2m
timeout: 10s
retries: 3
start_period: 2m
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
volumes:
flight-data:DB_PATH is pinned in the compose environment block (which overrides env_file) so the container always lands the DB on the named volume regardless of what .env carries — .env ships the local no-Docker default, and no edit is needed for either mode.
Why a named volume, not a host bind mount (this repo lives on a Windows host): SQLite in WAL mode writes .db-wal and .db-shm siblings that need coherent POSIX filesystem semantics; Docker Desktop's Windows bind-mount translation is exactly where WAL corruption and slow-locking gremlins live. A named volume keeps the whole DB family on one Linux filesystem inside the VM, and survives rebuilds/recreations. (Local no-Docker dev on Windows/NTFS is fine — only the Desktop bind mount is the hazard.)
No ports (zero inbound surface). No scaling — one SQLite writer (§5). Backup: docker compose stop → copy the named volume — WAL siblings must be checkpointed or copied together; the graceful shutdown checkpoints on stop. From a running container: docker compose exec flight-bot python -c "import sqlite3; sqlite3.connect('/data/flight_tracker.db').backup(sqlite3.connect('/data/backup.db'))", then docker cp. Upgrades are backward-safe: all DDL is CREATE IF NOT EXISTS keyed on meta.schema_version; a newer image never touches existing data.
# ============================================================
# Flight Tracker Bot configuration
# Copy to .env and fill in. NEVER commit .env.
# ============================================================
# --- Discord ---
# https://discord.com/developers/applications -> Bot -> Token
DISCORD_BOT_TOKEN=
# Channel ID that receives deal alerts (right-click channel -> Copy Channel ID)
DISCORD_CHANNEL_ID=
# Optional ops channel for system notices (quota, auth, outages). Empty = alerts channel.
DISCORD_ADMIN_CHANNEL_ID=
# Optional: sync slash commands instantly in this one server. Empty = global sync (up to 1h).
DISCORD_GUILD_ID=
# --- RapidAPI ---
# Subscribe to the chosen API's page first, or every call returns 403.
RAPIDAPI_KEY=
# google-flights2.p.rapidapi.com (default) or sky-scrapper.p.rapidapi.com
RAPIDAPI_HOST=google-flights2.p.rapidapi.com
# Must match the host: google-flights2 | sky-scrapper
API_PROVIDER=google-flights2
# --- Schedule & timezone ---
# All calendar dates and the run slot use this IANA zone. Algeria has no DST.
TIMEZONE=Africa/Algiers
RUN_TIME_LOCAL=09:15
# --- Tracking strategy ---
# One-way routes as ORIGIN-DEST IATA pairs
TRACKED_ROUTES=ALG-NRT,ALG-HND,ALG-KIX,ALG-ICN,ALG-PUS
# Window: departures D+LEAD_DAYS .. D+LEAD_DAYS+WINDOW_DAYS-1 (lead-time-stationary)
LEAD_DAYS=1
WINDOW_DAYS=30
# Market context for pricing/inventory (DZ = Algeria). Changing ANY methodology
# value below re-primes the 30-day baseline (7-day warmup) on purpose.
COUNTRY_CODE=DZ
CURRENCY=EUR
TRAVEL_CLASS=ECONOMY
ADULTS=1
# 0 = exclude separate-ticket itineraries from verification searches (safer, default)
SHOW_HIDDEN=0
# Retry an empty calendar window once via the Price Graph endpoint (+1 request)
USE_PRICE_GRAPH_FALLBACK=1
# --- Statistics & alerting ---
# Fire when today's low is at least this % below the 30-day rolling average
DROP_THRESHOLD_PCT=20
# Days of prior history required before any alert (warmup)
MIN_HISTORY_DAYS=7
# Symmetric trim of the baseline mean (% per tail)
BASELINE_TRIM_PCT=10
# Exclude baseline values outside [median/factor, median*factor]
SERIES_OUTLIER_FACTOR=3.0
# Re-alert suppression (Algiers days) + required deepening (percentage points)
ALERT_COOLDOWN_DAYS=7
RE_ALERT_DEEPEN_PCT=5
# Quote sanity band in EUR (kills glitch quotes and currency contamination)
FARE_FLOOR_EUR=80
FARE_CEILING_EUR=12000
# Re-fetch to confirm a drop before alerting (+1 request per alert candidate)
CONFIRM_BEFORE_ALERT=1
# Tag alerts "possibly stale" after N identical price vectors in a row
STALE_STREAK_RUNS=3
# --- HTTP behavior ---
REQUEST_TIMEOUT_SECONDS=20
RETRY_MAX_ATTEMPTS=3
RETRY_BACKOFF_SECONDS=5
INTER_CALL_DELAY_SECONDS=2
# Health-check call at run start (costs 1 request; auth errors surface on the
# first real call anyway)
CHECK_SERVER_EACH_RUN=0
# resolve = 3 extra requests per sent alert for a real checkout link
# static = free hand-built Google Flights search URL (BASIC-tier setting)
BOOKING_LINK_MODE=resolve
# --- RapidAPI monthly quota ---
# google-flights2: BASIC 150/mo HARD | PRO 40,000/mo. sky-scrapper: 20/mo | 10,600/mo.
# Defaults below assume PRO. On free BASIC, also set: USE_PRICE_GRAPH_FALLBACK=0,
# CONFIRM_BEFORE_ALERT=0, BOOKING_LINK_MODE=static, PLAN_MONTHLY_LIMIT=150 — the
# provider's own header still binds the true 150 hard limit either way.
PLAN_MONTHLY_LIMIT=40000
# A run needs at least this many requests remaining after it finishes
QUOTA_RESERVE_REQUESTS=6
# --- Storage ---
# Local no-Docker default (sqlite creates it in the working directory).
# docker-compose.yml pins /data/flight_tracker.db for containers — no edit needed
# for either mode. database.py mkdirs the parent before the first connect.
DB_PATH=./flight_tracker.db
LOG_LEVEL=INFOStartup validation (fail-fast, in bot.py): required vars present; API_PROVIDER ∈ {google-flights2, sky-scrapper} and matches RAPIDAPI_HOST; TRACKED_ROUTES parse as XXX-YYY; LEAD_DAYS ≥ 1; 1 ≤ WINDOW_DAYS ≤ 60; 0 < DROP_THRESHOLD_PCT ≤ 90; MIN_HISTORY_DAYS ≥ 3; 0 ≤ BASELINE_TRIM_PCT < 25; FARE_FLOOR < FARE_CEILING; ZoneInfo(TIMEZONE) constructs (crashes loudly on missing tzdata rather than silently running on UTC); RUN_TIME_LOCAL parses as HH:MM. Config errors exit non-zero (the restart policy keeps surfacing them); data/API errors never exit.
| Risk | Mitigation |
|---|---|
Quota cliff — BASIC (150/mo) funds only ~28 of the 30–31 core runs under the 11-request pre-run gate — 2–3 quota_low tail days every month even with the degradation set; the default profile (155–170+/mo) is a PRO-tier profile |
Pre-run gate before spending; mid-run QuotaExhausted abort; local counter cross-checked with headers (provider header binds even if the limit is mis-set); link chain quota-gated; documented BASIC degradation set; PRO recommended |
| Fake-cheap quote → false alert (the worst failure: user books a nonexistent deal, stops trusting the bot) | Four stacked tiers: quote band (80–12k EUR) → median/3.0 series band → 10% trimmed mean → confirmation re-fetch before any send (on unless CONFIRM_BEFORE_ALERT=0 on BASIC) |
| ALG→PUS empties are the norm | empty outcome, NULL low, zero retries, route_no_data notice after 3 consecutive days; plan documents it as expected behavior for that route |
| Timezone skew (UTC container vs Algiers days → mislabeled dates, double/skipped days) | Every date from ZoneInfo(TIMEZONE) computed once per run; 09:15 slot far from midnight; tzdata pinned; explicit dates on every API call; PK-based exactly-once |
| Methodology drift (changing country_code/provider mid-series → phantom drops) | params_hash on every run/stat; baselines same-fingerprint only; changes re-prime with visible warmup |
| 429 triple meaning (throttle vs hard-limit vs missing header) | Header + Retry-After triangulation; conservative abort branch; 429-path logging with masked key |
| SQLite corruption (second writer, volume damage) | Single-instance/no-scale rule; WAL + short transactions; SIGKILL-safe by construction; quick_check at startup → quarantine + salvage + db_corrupt notice (§5 kind enum) as the documented runbook |
| Discord delivery failure (channel deleted, permissions, outage) | Decoupled pending→sent machine, ≤10 retries, discord_send_failed notice, alerts visible in /status |
| Provider shape drift (the upstream is a scraper) | Defensive parsing degrades to invalid/NULL rows (never a crash); all-empty-run tripwire notices within a day; raw layer preserves what came in |
| First-week silence (min-history guard) | By design — warmup progress visible in /status; documented in the runbook so day-1 silence isn't read as breakage |
First deploy: ① Discord developer portal — create app → Bot → copy DISCORD_BOT_TOKEN; invite with bot + applications.commands scopes; note the alert channel ID (and optional admin channel ID). No privileged intents needed. ② RapidAPI — subscribe to the Google Flights 2 API page (or every call 403s); copy RAPIDAPI_KEY; decide your tier — the shipped defaults are the PRO profile (confirmation re-fetch, price-graph fallback, resolved booking links); on free BASIC flip the four knobs marked in the quota section of .env.example (see §9 cost model). ③ cp .env.example .env, fill in. ④ docker compose up -d --build. ⑤ Check /status — routes seeded, warmup counters visible. Expect no alerts for the first 7 days (baseline warmup, by design).
Day-to-day: /status (per-route state, quota, last run); docker logs flight-tracker-bot. The three expected operational events: 401/403 → fix key/subscription in the RapidAPI dashboard, no restart needed (next day's run self-heals); quota exhausted → upgrade tier or wait for monthly reset (the bot skips runs safely and says so); empty PUS → expected, do nothing.
Verification plan for the implementation phase (in order):
python tracker.pystandalone (no Discord): seeds DB, runs one live cycle, prints per-route outcomes — validates adapters, parsing, validation, and stat derivation in isolation. Check the printed lows are EUR-plausible (they must sit inside the §7 80–12,000 EUR band; a DZD/USD-scale reading means the adapter is mislabeling currency — stop and fix before anything else).- DB inspection via stdlib Python (the
sqlite3CLI ships neither on stock Windows 11 nor inpython:3.11-slim):python -c "import sqlite3; c=sqlite3.connect('flight_tracker.db'); c.row_factory=sqlite3.Row; [print(t, c.execute(f'select count(*) from {t}').fetchone()[0]) for t in ['meta','routes','runs','fare_quotes','route_day_stats','alerts','system_alerts']]; r=c.execute('select * from route_day_stats order by stat_date desc limit 1').fetchone(); print(dict(r) if r else 'no stats yet')"(connect to whateverDB_PATHstep 1 used) — row counts per table plus a stat row's full audit columns. - Synthetic alert test on a scratch DB: seed ≥ 7 synthetic prior
route_day_statsrows per route (carrying the currentparams_hash,daily_low_eurwell above today's expected low so no median-band exclusions bite), set onlyDROP_THRESHOLD_PCT=1, run the bot end-to-end (embed + booking chain + delivery machine), then reset config. Click the static fallback link variant too and confirm it opens a one-way search for the intended route and date. docker compose config→up -d --build→ healthcheck goes healthy →/checkflightsand/statusrespond.- Restart durability:
docker compose restartmid-day → no double-run, no duplicate alerts, heartbeat resumes. - Error paths: bad key (expect clean
auth_error+ notice), unreachable channel (expectdiscord_send_failed+ retry, alert still queryable).
Implementation order: database.py → tracker.py → standalone smoke test → bot.py → healthcheck.py → Docker files → end-to-end verification.
Out of scope for v1 (each documented, not built): per-departure-date alerting (today's route-level window low is the recorded metric per spec); automatic cross-provider failover (switching providers re-primes the series — a deliberate .env change); per-itinerary carrier/stops detail (5×N calls — paid tier only); sub-daily cadence (paid tier); backfill of missed days (perishable data).
.gitignore covers .env, *.db, *.db-wal, *.db-shm, .research_gf2/, __pycache__/, and litellm_config.yaml — that file contains a live-looking API key in plaintext; it must not be committed, and the key should be rotated if it has ever been shared. If this repo will live on a remote, consider moving the litellm config out of the project directory entirely.