Skip to content

Architecture

This document describes the internal architecture of Nexar using ASCII flow diagrams.

Generated by Deepseek

Overview

                    +-------------------+
                    |    Your Code      |
                    +---------+---------+
                              |
                              v
                    +-------------------+
                    |   NexarClient     |
                    |  (public API)     |
                    +--------+----------+
                             |
              +--------------+--------------+
              |              |              |
              v              v              v
    +--------------+  +-----------+  +------------+
    |  RateLimiter |  |   Cache   |  |  Models    |
    |  (leaky      |  | (SQLite / |  | (Player,   |
    |   buckets)   |  |  memory)  |  |  Match...) |
    +--------------+  +-----------+  +------------+
              |              |
              v              v
        Riot API Edge   aiohttp-client-cache

Component: NexarClient

The NexarClient is the single entry point. Every API call flows through _make_api_call(), which orchestrates caching and rate limiting.

Request flow — cache enabled

 CALLER
   |
   |  e.g. client.get_match("NA1_12345")
   v
 _make_api_call(endpoint, region, ...)
   |
   +--[1]--> _ensure_session()
   |           |  Creates CachedSession (SQLite or memory backend)
   |           |
   +--[2]--> _try_cache_response(url, headers, params)
   |           |
   |           +--- HIT --->  return cached data  (increments cache_hits)
   |           |
   |           +--- MISS ---> continue
   |
   +--[3]--> rate_limiter.acquire(region, method_id)
   |           |
   |           |  Blocks until app + method + service buckets
   |           |  all have capacity. Observes Retry-After blocks.
   |           |
   +--[4]--> session.get(url, headers, params)
   |           |
   |           +--- 200 OK ---> _record_success_response()
   |           |                   |  update_from_headers()
   |           |                   |  handle_response_errors()
   |           |                   +-- return response data
   |           |
   |           +--- 429 -----> retry loop
   |           |                   |  update_from_headers()
   |           |                   |  apply Retry-After blocking
   |           |                   +-- back to [3]
   |           |
   |           +--- 4xx/5xx --> raise RiotAPIError subclass
   |
   v
 RETURN data (dict parsed into model)

Request flow — cache disabled

 CALLER
   |
   v
 _make_api_call(endpoint, region, ...)
   |
   +--[1]--> _ensure_session()
   |           |  Creates plain aiohttp.ClientSession
   |           |
   +--[2]--> _try_cache_response()  →  always returns None
   |
   +--[3]--> rate_limiter.acquire(region, method_id)
   |
   +--[4]--> session.get(url, headers, params)
   |           |
   |           +--- 200 OK ---> return response data
   |           +--- 429 -----> retry loop (up to 5 attempts)
   |           +--- error ---> raise
   |
   v
 RETURN data

Retry loop detail

 for attempt in range(max_retries=5):
     |
     +-- acquire rate limit slot
     |
     +-- GET request
     |     |
     |     +--- 200/304 ---> SUCCESS (return)
     |     |
     |     +--- 429 -------> update_from_headers()
     |     |                  retries += 1
     |     |                  continue loop
     |     |
     |     +--- network err-> retries += 1
     |                        exponential backoff: 2^attempt seconds
     |                        if last attempt: raise RiotAPIError
     |
     +-- if loop exhausted: raise RateLimitError

Component: RateLimiter

The RateLimiter uses aiolimiter.AsyncLimiter (leaky bucket algorithm) to enforce Riot's rate limits across three tiers.

Bucket hierarchy

                        +----------------------+
                        |    APP LIMITS        |  <-- static, from config
                        | e.g. 20/1s, 100/120s |
                        +----------+-----------+
                                   |
                        +----------+-----------+
                        |   SERVICE LIMITS     |  <-- dynamic, from headers
                        | per-region, per-svc  |
                        +----------+-----------+
                                   |
                        +----------+-----------+
                        |   METHOD LIMITS      |  <-- dynamic, from headers
                        | per-region, per-method|
                        +----------------------+

acquire() flow

 rate_limiter.acquire(region="na1", method="league-v4")
   |
   +-- acquire region lock (asyncio.Lock)
   |
   +-- LOOP:
   |     |
   |     +-- calc_block_wait: are we in a Retry-After penalty?
   |     |
   |     +-- collect_buckets(region, method)
   |     |     |  app buckets        (static, per-region)
   |     |     |  + service buckets  (dynamic, matching region)
   |     |     |  + method buckets   (dynamic, matching region+method)
   |     |
   |     +-- all have_capacity()?
   |     |     |
   |     |     +--- YES + no block --> break loop
   |     |     +--- NO  ------------> sleep(longest of block_wait or refill_wait),
   |     |                              log reason, loop again
   |
   +-- acquire() on every collected bucket (atomic under lock)
   |
   v
 REQUEST ALLOWED

429 handling flow

 update_from_headers(headers, region, method)
   |
   +-- Retry-After present?
   |     |
   |     +--- YES --> _apply_retry_after_block()
   |     |              |
   |     |              +-- detect rate limit type from headers
   |     |              |     |  "application" -> block app buckets
   |     |              |     |  "method"      -> block method buckets
   |     |              |     |  "service"     -> block service buckets
   |     |              |     |  (absent)      -> block ALL region buckets
   |     |              |
   |     |              +-- if service/method 429 lacks its own limit
   |     |                  header, also block app buckets (cascading)
   |     |
   +-- Parse X-Method-Rate-Limit + X-Method-Rate-Limit-Count
   |     |  Create/replace dynamic method AsyncLimiter
   |     |  Key: method_{region}_{method}_{limit}:{window}
   |
   +-- Parse X-Service-Rate-Limit + X-Service-Rate-Limit-Count
         |  Create/replace dynamic service AsyncLimiter
         |  Key: service_{region}_{limit}:{window}

App bucket creation

Nexar uses aiohttp-client-cache for transparent HTTP caching.

Backend selection

 CacheConfig
   |
   +-- backend = "sqlite" (default)
   |     |
   |     +-- SQLiteBackend
   |     |     cache_dir: ~/.nexar/ (or custom)
   |     |     file:      {cache_name}.sqlite
   |     |
   |     +-- Persistent across restarts
   |
   +-- backend = "memory"
         |
         +-- DictCache
         |     stored in-process, ephemeral
         |
         +-- Lost on process exit

Smart endpoint TTLs

 Endpoint pattern                                    TTL
 ──────────────────────────────────────────────────  ──────────
 */lol/match/v5/matches/*                            forever
 */lol/league/v4/entries/by-puuid/*                  5 minutes
 */lol/match/v5/matches/by-puuid/*/ids               1 minute
 */riot/account/v1/accounts/by-riot-id/*             24 hours
 */lol/summoner/v4/summoners/by-puuid/*              24 hours
 (anything else)                                     1 hour (default)

Cache lookup flow

 _try_cache_response(url, headers, params)
   |
   +-- session is CachedSession?  ---- NO ---> return None
   |
   +-- build cache key from (GET, url, params, headers)
   |
   +-- cache.get_response(cache_key)
   |     |
   |     +--- HIT + response.ok --> return response.json()
   |     |                          cache_hits += 1
   |     |
   |     +--- MISS or expired ----> return None

When cache is disabled

 CacheConfig(enabled=False)  →  NO_CACHE_CONFIG

 _ensure_session() creates plain aiohttp.ClientSession

 _try_cache_response() short-circuits (not a CachedSession)

 All requests go directly to Riot API (still rate limited)

Component: Models

API responses are parsed into typed Python objects. Each model class has a from_api_response() factory.

 JSON response (dict)
   |
   v
 Model.from_api_response(data)
   |
   +-- Extracts fields
   +-- Converts nested objects to sub-models
   +-- Validates types
   |
   v
 Typed dataclass (Player, Match, LeagueEntry, Summoner, etc.)

High-level Player convenience

 NexarClient.get_player(game_name="bexli", tag_line="bex")
   |
   +-- Resolves region from arg or default
   |
   +-- Player.create()
   |     |
   |     +-- get_riot_account()  →  RiotAccount (PUUID)
   |     +-- get_summoner_by_puuid()  →  Summoner (level, icon)
   |     +-- get_league_entries_by_puuid()  →  list[LeagueEntry]
   |
   v
 Player object with all data pre-fetched

Full request lifecycle (end-to-end)

 User calls client.get_match("NA1_12345")
   |
   v
 NexarClient._make_api_call("/lol/match/v5/matches/NA1_12345", "na1", "americas")
   |
   +-- Build URL: https://americas.api.riotgames.com/lol/match/v5/matches/NA1_12345
   |
   +-- _ensure_session() → CachedSession (or ClientSession if no cache)
   |
   +-- _try_cache_response()
   |     |
   |     +--- HIT ---> return cached JSON ──────────────────────┐
   |     |                                                        |
   |     +--- MISS ---> continue                                  |
   |                                                              |
   +-- rate_limiter.acquire("na1", "match-v5")                    |
   |     |  Checks app buckets: 20/1s + 100/120s                  |
   |     |  Checks service buckets (if any from prior requests)   |
   |     |  Checks method buckets (if any)                        |
   |     |  Blocks if needed, then acquires                       |
   |                                                              |
   +-- session.get(url) ─────────────────────────────────────────┐|
   |     |                                                        ||
   |     +--- 200 ---> _record_success_response()                 ||
   |     |     |  update_from_headers() - sync rate limit state   ||
   |     |     |  handle_response_errors() - raise if not ok      ||
   |     |     |  return JSON ────────────────────────────────────┘|
   |     |                                                         |
   |     +--- 429 ---> retry loop                                  |
   |     +--- 4xx ---> raise NotFoundError / UnauthorizedError     |
   |                                                               |
   v                                                               v
 Match.from_api_response(data)                              Cached response
   |
   v
 Match object returned to caller