Files
ticket-monitoring/docs/design-notes.md
T
Aculix Technologies 474415bb55 fix: re-alert when withdrawn shows are re-listed
District published Endgame showtimes, the monitor alerted, and then the shows were
pulled. Dedupe remembered every session id it had announced, and re-listings often
reuse the same ids, so the moment bookings actually opened would have been silent.
It only re-armed this time because a redeploy happened to wipe the state file.

A session missing for three consecutive successful checks is now forgotten, so its
return alerts again. Three rather than one, so a flaky response can't produce a
duplicate max-priority push; failed checks don't count. When the whole date goes
dark after opening, a separate notice says the shows were withdrawn. The re-arm
lives in the base state rather than that notice's patch, so a lost notification
can't leave the monitor disarmed.
2026-09-10 21:06:19 +05:30

123 lines
7.8 KiB
Markdown

# Design notes
Why this is built the way it is, and what broke along the way. Useful if you're forking it,
or writing something similar against a different site.
Two sites are supported. Each lives in `src/providers/` and exposes the same two functions,
`check()` and `extract()`, returning sessions in one normalized shape. Everything above that
layer, including the alert state machine, has no idea which site a session came from.
## District: the API
District's movie pages are server-rendered Next.js, but showtimes load client-side from:
```
GET /gw/consumer/movies/v5/movie
?version=3&site_id=1&channel=mweb&child_site_id=1&platform=district
&movieCode=<frmtid>&city_key=<city>&content_id=<id>
&date=YYYY-MM-DD&latitude=<lat>&longitude=<lng>&cinemaOrderLogic=3
```
Findings that shaped the design:
- **`x-guest-token` is generated client-side.** It looks like a session token but isn't
server-issued — the browser builds `<epoch_ms>_<18 digits>_<11 random chars>` per request, and
any value of that shape is accepted. No login, no cookies.
- **Latitude and longitude are mandatory.** Without them the API returns `400 Mandatory search
params are missing`, even though results are city-wide.
- **`204` vs `200` is the signal.** A date that isn't open for booking returns `204 No Content`;
an open date returns the full session list. That status flip is the entire trigger, which keeps
the check to a single cheap request.
- **`showTime` is UTC** without a zone suffix. Local time is applied via `TZ_OFFSET_MINUTES`.
- **`meta.showDates` only exists in a `200` body.** Because the target date returns an empty
`204` while closed, the daily heartbeat makes one extra call for *today* purely to report how
far ahead bookings currently run. That probe is best-effort and never counts as a failure.
## BookMyShow: the API
```
GET /api/movies-data/v5/showtimes-by-event/primary-dynamic
?etCodes=<ET code>&dateCode=YYYYMMDD&regionCode=AHD
&isDesktop=true&xLocationShared=false&memberId=&lsId=&subCode=
&appCode=WEB&refEventCode=<ET code>[&language=hindi]
```
Sent with `x-region-code`, `x-region-slug`, `x-latitude`, `x-longitude`, `x-app-code: WEB` and
an `x-bms-id` that, like District's guest token, the web client makes up locally.
- **Cloudflare blocks on TLS fingerprint, not IP or headers.** curl and Node's `fetch` both get
403 with a byte-perfect copy of the browser's headers, and no `cf_clearance` cookie is involved,
so there is nothing to copy across. A proxy does not help either, since the block happens during
the TLS handshake. [impit](https://github.com/apify/impit) performs the request with a real
Chrome fingerprint and returns 200. It ships prebuilt binaries for linux x64 and arm64 in both
gnu and musl flavours, which is what keeps the Alpine image and the arm64 build working.
- **There is no 204.** A date that is not on sale answers 200 with a short body containing only
`header`, `additionalData` and `addOnWidgets`. The tell is that `data.showtimeWidgets` is
absent, so "closed" is a property of the payload rather than the status code.
- **A past or invalid date returns 400**, which counts as a failure rather than "closed". That is
deliberate: it means something is wrong with the configuration, and you should hear about it.
- **Sessions are buried in generic UI widgets.** Venue cards sit several layers down inside
`showtimeWidgets`, so the parser walks the tree looking for `type: "venue-card"` instead of
indexing through fixed positions, which would break the moment BMS reorders its layout.
- **Seat detail lives in a bottom sheet.** Each showtime carries a `customGestureCTA` describing
the panel shown on double-tap, and that is the only place per-tier prices appear. BMS reports
availability as words (`AVAILABLE`, `FILLING FAST`, `ALMOST FULL`) and never as exact counts,
unlike District, so the normalized session leaves `avail` and `total` null and alert lines omit
the seat figures for this site.
- **Times are already local**, so unlike District's UTC timestamps they are passed through as-is.
## Alert state machine
`decideProvider()` in `src/monitor.js` is pure: state in, `{ baseState, alerts }` out. That makes every
edge case testable without network or clock mocking, and it's where the interesting rules live.
- **Publish before persist.** Each alert carries its own `statePatch`, merged only after that
alert's push succeeds. A failed notification retries on the next tick instead of being silently
recorded as delivered.
- **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you.
- **Dedupe does not outlive the listing.** Cinemas publish early, pull the shows and re-list
them, often under the same session ids, and this really happened with the Endgame release. A
show missing for three consecutive successful checks is forgotten, so its return alerts again.
Three rather than one, so a single flaky response can't cause a duplicate max-priority push,
and failed checks don't count, since an outage says nothing about whether a show exists. If
the whole date goes dark after opening, a separate notice says the shows were withdrawn. The
re-arm itself lives in the base state, not in that notice's patch, so it happens even if the
notice fails to deliver.
- **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push
followed by sessions disappearing could swallow the only notification you'd ever get.
- **Open-but-empty counts as closed**, defending against a `200` with no sessions.
- **Failures are capped at the threshold**, so a long outage stops writing state entirely.
- **State is written only when it actually changed.** Originally this ran on Cloudflare KV with a
1,000 writes/day free limit against 1,440 daily ticks, which forced the discipline; it's still
the right behaviour on a filesystem.
- **State is namespaced per site.** Each provider gets its own dedupe map, failure counter and
BROKEN flag under `state.providers.<key>`, so the same session id can alert once per site and a
site going down never marks the other as broken. Retirement and the daily heartbeat are the only
tick-level decisions, and they live in `decideGlobal()`.
The guiding rule throughout: **degrade toward duplicate notifications, never toward silence.**
Every failure path, whether it is unreadable state, a failed push or a failed write, errs that way.
## What broke in production
Both failures were about *where the traffic came from*, not the code.
1. **Akamai started returning `403` to the cloud host after ~2 days.** The first version ran as a
Cloudflare Worker on a 1-minute cron. It worked, alerted correctly in a fire drill, then hard-
blocked — while the same requests from a home connection kept working. Datacenter IP ranges
get flagged; residential ones didn't. The monitor's own BROKEN alert is what surfaced this,
which is the strongest argument for having it.
2. **ntfy.sh returned `429` to that same class of IP.** Push delivery failed from cloud egress
even with an account token, because rate limits are per visitor IP and shared cloud IPs are
already spent. Self-hosting ntfy fixed it immediately.
The fix for both: run it from a residential connection, or route only the site checks through a
SOCKS5 proxy (`PROXY_URL`) and use your own ntfy server. Notifications deliberately stay direct
so the alerting path has no dependency on the proxy.
**Cloudflare Workers cannot do this.** `fetch()` there has no proxy option, and the raw-TCP
escape hatch (`connect()` from `cloudflare:sockets`) can't help: `startTls()` validates the
certificate against the host you dialed, with no SNI override, so you can't form a TLS session
for a third-party origin through a proxy. That's why the project ended up as a plain Node
process in Docker rather than a serverless function.