Phase 5: ESI Expires caching, error-limit back-off, setup docs (#16)
EsiClient now caches GET responses honoring the Expires header and backs off when the rolling ESI error window is nearly exhausted (X-Esi-Error-Limit-*). README gains Setup / Connect-to-Claude / enabling-writes sections. Closes #16. SDE bulk-load split out to its own follow-up (#51). 135 tests, coverage 90%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
3 files changed
+146
-2
No files matched your search
@@ -19,7 +19,43 @@ Two data sources sit behind the server:
|
||||
- **ESI** (the [EVE Swagger Interface](https://esi.evetech.net/)) — the official REST API. Public endpoints (market, universe, industry indices) need no login. Character and corp data (wallet, assets, skills, industry, contracts, mail, location, and more) come through EVE SSO with **read and write OAuth2 scopes** across both character and corp; write scopes power the opt-in actions.
|
||||
- **SDE** (the [Static Data Export](https://developers.eveonline.com/resource/resources)) — the full static dataset of items, blueprints, systems and stations, cached locally for fast lookups and anything name- or recipe-based.
|
||||
|
||||
Built in **Python with FastMCP**, OAuth via PKCE with a localhost callback, refresh token stored encrypted on disk. ESI `Expires` headers are respected for caching, and the SDE is loaded into a local store.
|
||||
Built in **Python with FastMCP**, OAuth via PKCE with a localhost callback, refresh token stored encrypted on disk. ESI `Expires` headers are respected for caching, the rolling ESI error limit is honored with back-off, and (eventually) the SDE is loaded into a local store.
|
||||
|
||||
## Setup
|
||||
|
||||
Requires [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
```bash
|
||||
uv sync # install
|
||||
uv run familiar login # browser EVE SSO login; run once per character
|
||||
uv run familiar status # list logged-in characters
|
||||
```
|
||||
|
||||
Re-run `login` for each character (switch EVE accounts in the browser as needed); they all live in one encrypted store. Changing the requested scopes requires logging in again.
|
||||
|
||||
### Connect to Claude
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add familiar -- uv --directory /path/to/familiar run familiar
|
||||
```
|
||||
|
||||
Claude Desktop (`claude_desktop_config.json`):
|
||||
|
||||
```json
|
||||
{ "mcpServers": { "familiar": { "command": "uv", "args": ["--directory", "/path/to/familiar", "run", "familiar"] } } }
|
||||
```
|
||||
|
||||
Then ask in natural language — "what's Tritanium worth in Jita?", "how much ISK across all my characters?", "where are my characters?".
|
||||
|
||||
### Enabling writes
|
||||
|
||||
Write actions (send mail, set waypoints, edit contacts/fittings, fleet commands) are **off by default**. Every write tool also returns a dry-run preview unless called with `confirm=true`, and each executed write is recorded in an audit log.
|
||||
|
||||
```bash
|
||||
uv run familiar writes enable # allow writes (uv run familiar writes disable to revoke)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -51,7 +87,7 @@ Built in phases. Each phase is independently useful, so the project stays shippa
|
||||
|
||||
## Status
|
||||
|
||||
Phases 0–2 done on `dev`: public market & universe lookups, EVE SSO (PKCE) login with an encrypted token store, and read-only account tools (wallet, skills, location, clones, assets) across multiple characters and accounts. Now expanding to full character + corp coverage with opt-in writes (Phases 3–5). `main` stays untouched until the first complete version is ready.
|
||||
Phases 0–4 done on `dev`, plus Phase 5 caching/back-off and docs: public market & universe lookups; EVE SSO (PKCE) login with an encrypted, multi-character token store; full character **and** corp coverage across skills, wallet, assets, industry, trade, structures, killmails, profile, fleet, mail, contacts, fittings, PI and more; and opt-in, audited write actions. ~80 tools. Remaining: the full SDE bulk-load. `main` stays untouched until the first complete version is ready.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ instead of hitting the live API.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -20,6 +23,24 @@ DATASOURCE = os.environ.get("FAMILIAR_ESI_DATASOURCE", "tranquility")
|
||||
USER_AGENT = (
|
||||
f"familiar/{__version__} (+https://git.hiddenden.cafe/Hiddenden/familiar; read-only MCP)"
|
||||
)
|
||||
# Back off when the rolling ESI error window has this few requests left.
|
||||
ERROR_LIMIT_THRESHOLD = int(os.environ.get("FAMILIAR_ERROR_LIMIT_THRESHOLD", "5"))
|
||||
CACHE_DISABLED = os.environ.get("FAMILIAR_DISABLE_CACHE", "").lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
async def _sleep(seconds: float) -> None:
|
||||
"""Indirection so tests can patch the back-off sleep."""
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
def _expiry_from_headers(headers: httpx.Headers) -> float | None:
|
||||
expires = headers.get("expires")
|
||||
if not expires:
|
||||
return None
|
||||
try:
|
||||
return parsedate_to_datetime(expires).timestamp()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class EsiError(RuntimeError):
|
||||
@@ -48,6 +69,11 @@ class EsiClient:
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
# (body, expiry_epoch) keyed by request, honoring the Expires header.
|
||||
self._cache: dict[Any, tuple[Any, float]] = {}
|
||||
# Rolling ESI error-limit window (from response headers).
|
||||
self._error_remain: int | None = None
|
||||
self._error_reset_at: float = 0.0
|
||||
|
||||
async def get(
|
||||
self, path: str, params: dict[str, Any] | None = None, token: str | None = None
|
||||
@@ -108,6 +134,14 @@ class EsiClient:
|
||||
) -> Any:
|
||||
merged = {"datasource": DATASOURCE, **(params or {})}
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
|
||||
cache_key = (method, path, tuple(sorted(merged.items()))) if method == "GET" else None
|
||||
if cache_key is not None and not CACHE_DISABLED and not with_headers:
|
||||
hit = self._cache.get(cache_key)
|
||||
if hit is not None and hit[1] > time.time():
|
||||
return hit[0]
|
||||
|
||||
await self._respect_error_limit()
|
||||
try:
|
||||
resp = await self._client.request(
|
||||
method, path, params=merged, json=json, headers=headers
|
||||
@@ -115,6 +149,8 @@ class EsiClient:
|
||||
except httpx.HTTPError as exc: # network/timeout
|
||||
raise EsiError(f"ESI request to {path} failed: {exc}") from exc
|
||||
|
||||
self._update_error_limit(resp.headers)
|
||||
|
||||
if resp.status_code >= 400:
|
||||
detail = _error_detail(resp)
|
||||
raise EsiError(
|
||||
@@ -122,10 +158,34 @@ class EsiClient:
|
||||
status=resp.status_code,
|
||||
)
|
||||
body = None if not resp.content else resp.json()
|
||||
|
||||
if cache_key is not None and not CACHE_DISABLED and not with_headers:
|
||||
expiry = _expiry_from_headers(resp.headers)
|
||||
if expiry is not None:
|
||||
self._cache[cache_key] = (body, expiry)
|
||||
|
||||
if with_headers:
|
||||
return body, resp.headers
|
||||
return body
|
||||
|
||||
async def _respect_error_limit(self) -> None:
|
||||
"""If the error window is nearly exhausted, wait for it to reset."""
|
||||
if self._error_remain is not None and self._error_remain <= ERROR_LIMIT_THRESHOLD:
|
||||
wait = self._error_reset_at - time.time()
|
||||
if wait > 0:
|
||||
await _sleep(wait)
|
||||
self._error_remain = None
|
||||
|
||||
def _update_error_limit(self, headers: httpx.Headers) -> None:
|
||||
remain = headers.get("x-esi-error-limit-remain")
|
||||
reset = headers.get("x-esi-error-limit-reset")
|
||||
if remain is not None:
|
||||
try:
|
||||
self._error_remain = int(remain)
|
||||
self._error_reset_at = time.time() + int(reset or "0")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
@@ -8,6 +8,54 @@ import pytest
|
||||
from familiar import esi
|
||||
|
||||
|
||||
_FUTURE = "Wed, 21 Oct 2099 07:28:00 GMT"
|
||||
|
||||
|
||||
def test_get_caches_when_expires_present(mock_esi):
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(req):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"v": 1}, headers={"Expires": _FUTURE})
|
||||
|
||||
mock_esi(handler)
|
||||
first = asyncio.run(esi.client().get("/x/"))
|
||||
second = asyncio.run(esi.client().get("/x/"))
|
||||
assert first == second == {"v": 1}
|
||||
assert calls["n"] == 1 # second served from cache
|
||||
|
||||
|
||||
def test_no_cache_without_expires(mock_esi):
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(req):
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"v": 1})
|
||||
|
||||
mock_esi(handler)
|
||||
asyncio.run(esi.client().get("/y/"))
|
||||
asyncio.run(esi.client().get("/y/"))
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_error_limit_triggers_backoff(mock_esi, monkeypatch):
|
||||
slept = []
|
||||
|
||||
async def fake_sleep(s):
|
||||
slept.append(s)
|
||||
|
||||
monkeypatch.setattr(esi, "_sleep", fake_sleep)
|
||||
mock_esi(
|
||||
lambda req: httpx.Response(
|
||||
200, json={}, headers={"X-Esi-Error-Limit-Remain": "1", "X-Esi-Error-Limit-Reset": "3"}
|
||||
)
|
||||
)
|
||||
c = esi.client()
|
||||
asyncio.run(c.get("/a/")) # records remain=1
|
||||
asyncio.run(c.get("/b/")) # next request backs off before firing
|
||||
assert slept and slept[0] > 0
|
||||
|
||||
|
||||
def test_get_raises_esi_error_on_http_error(mock_esi):
|
||||
mock_esi(lambda req: httpx.Response(404, json={"error": "not found"}))
|
||||
with pytest.raises(esi.EsiError) as exc:
|
||||
|
||||
Reference in new issue
Block a user