Skip to content

Architecture notes

This is the "why does it work this way" doc. For endpoints see api.md; for every tunable see configuration.md.

Process model

Single process, single uvicorn worker, on purpose. The ingest loop and the character maintain-loop (token refresh, skills/standings/own-orders sync) run as asyncio tasks inside the FastAPI lifespan (backend/app/api/main.py). Multiple workers would each run their own copies of these loops against the same ESI account/IP, multiplying request volume for no benefit on a single-user tool — this is why just prod pins --workers 1 and why you shouldn't change that.

ESI hygiene (the EULA-relevant part)

backend/app/esi/client.py is the one place all ESI calls go through, and it enforces CCP's Third-Party Developer policy mechanically rather than by convention:

  • Never polls before Expires. Every cached response's expiry is tracked; a request for a URL that hasn't expired yet is served from cache with zero HTTP calls.
  • ETag / If-None-Match on every conditional request, so an unchanged resource costs a cheap 304 instead of a full payload.
  • Both rate-limit systems tracked: the error-limit header (ESI bans IPs that accumulate too many 4xx/5xx in a rolling window) and the normal per-route rate limit. Retry-After is honoured on 420/429/503.
  • Request coalescing: concurrent callers asking for the same in-flight URL share one HTTP request (via asyncio futures) instead of firing duplicates — relevant during a paginated pull where many pages are requested near-simultaneously.
  • Pagination metadata is cached alongside the payload. Cache entries store the X-Pages header; a cache hit returns it too. (This was the source of a real bug — see the pagination truncation note below.)

If you're extending flipdesk with a new ESI call, route it through this client. Don't add a second HTTP path that bypasses the cache/rate-limit bookkeeping — that's the one hard rule.

Hub caching

backend/app/ingest/region_book.py scans one "hub" (an NPC station or a citadel structure) at a time — whichever is char.selected_hub_id. Switching hubs in the UI doesn't necessarily trigger a fresh ESI pull: if the target hub was pulled within FLIPDESK_HUB_CACHE_S (default 300s), the ingest loop serves the existing SQLite rows and just re-runs the scan (spread detection) against them — a "cache hit" cycle with zero ESI calls. This sits under the ESI Expires ceiling as an additional throttle specifically for the "user is flipping back and forth between hubs" case, which would otherwise be a legitimate but wasteful pull pattern.

Structure discovery (which citadels your character can currently access) is a separate, slower cadence (FLIPDESK_HUB_DISCOVERY_INTERVAL_S, default 900s) — it's a different, heavier ESI call (corp structure list) than an orderbook pull. A 403 from that endpoint (character lacks docking/scan rights, or corp API access changed) is cached as a cooldown (FLIPDESK_HUB_CORP_403_COOLDOWN_S, default 6h) rather than retried every cycle — this was a deliberate fix for 403 error-log spam.

Reprice advisor

backend/app/engine/reprice.py, one instance shared across requests, evaluates every open order at the selected hub and decides hold / modify / recreate / cancel:

  1. Self-trade guard. Top-of-book is computed excluding your own orders, so suggestions never tell you to undercut yourself. A suggested buy reprice is blocked outright if it would land at or above your own sell order (you'd fill yourself).
  2. Hysteresis. Drift smaller than max(price × REPRICE_HYSTERESIS_PCT, REPRICE_MIN_DRIFT_ISK) is ignored — without this, sub-tick noise would generate a new suggestion every scan.
  3. Cooldown. The same order/suggested-price pair won't be re-raised within REPRICE_COOLDOWN_S even if it keeps drifting, to avoid nagging.
  4. Cost comparison. modify vs. cancel+recreate are compared on actual fee cost (the relist discount applies to modify, not recreate) — the cheaper one wins.
  5. Margin re-check. For a buy reprice specifically, if chasing the market down would push the paired flip margin below REPRICE_MIN_MARGIN_PCT, the suggestion becomes cancel instead — the spread has collapsed and repricing would just chase a loss.

Order store: dedup and diffing

backend/app/db/store.py's replace_orders / replace_orders_at do an atomic delete+insert per hub, but ESI pagination can hand back the same order_id on consecutive pages if the book shifts mid-pull (later page = fresher state). Both methods deduplicate by order_id before insert (keeping the later occurrence) — without this, a UNIQUE constraint on order_id would raise on ingest. The same pass computes the {added, removed, updated, total} diff against the previous snapshot, which is what drives the "synced +N −N ~N" pill in the UI (RefreshDiff / /api/book/status.refresh).

A related historical bug: the ESI cache layer originally didn't preserve the X-Pages header on a cache hit, so get_all_pages defaulted pages to None1 on a served-from-cache pull, silently truncating a multi-page book down to page 1 (lost ~49k orders in one observed case). Fixed by storing pages in the cache entry alongside the payload — see test_esi_cache_pages.py for the regression test.

Fee derivation

backend/app/character/service.py derives your live broker fee and sales tax from skill levels (Broker Relations, Advanced Broker Relations, Accounting) and empire standings, rather than trusting a static config value once you're logged in. It name-verifies the skill type IDs against ESI on every login — if CCP ever remaps those IDs, the mismatch is detected and flipdesk falls back to the FLIPDESK_DEFAULT_* placeholders instead of silently computing wrong fees from the wrong skill. Negative empire standings (common on nullsec characters) correctly increase NPC station fees rather than being clamped to zero.

At citadels, skills don't apply — FLIPDESK_CITADEL_OWNER_FEE (the structure owner's set rate) plus the fixed 0.5% SCC surcharge is used instead.

Released under the MIT License.