Date: 2026-05-13 Audit scope: full SDK — tests, lint, mypy, source code, docs vs. reality, runtime behavior.
| Check | Result |
|---|---|
pytest |
129 / 129 passing (~43s) |
ruff check recost/ |
1 error (unused import) |
mypy recost/ (strict) |
35 errors across 6 files — README claims strict-clean. |
python -c "from recost import init" |
OK |
Functionally the SDK runs; the mypy posture is broken, there's a real thread-safety bug in the aggregator, fork-safety is absent (breaking prefork servers), and naming is fragmented (Recost / ReCost / EcoAPI* are all referenced somewhere). The 13 consolidated issues below collect 25 original findings into the smallest set of focused, independently-fixable PRs.
Priorities: P0 = correctness / shipping bug, P1 = should fix before next release, P2 = polish.
Body:
recost/_aggregator.py:127-132's flush() iterates self._buckets.values() while user threads concurrently call ingest() (via interceptor wrappers, which run on whatever thread issued the HTTP request). The flush is driven by a background threading.Timer. Concurrent dict mutation during iteration raises RuntimeError: dictionary changed size during iteration.
Real race under any non-trivial load. There is no lock anywhere in Aggregator.
Fix: wrap ingest, flush, and would_overflow with a threading.RLock. Add a regression test that pumps N worker threads into ingest while the timer thread calls flush.
Files: recost/_aggregator.py, tests/test_aggregator.py
Includes: original #1.
Body:
README.md and CLAUDE.md both advertise mypy --strict cleanliness. mypy recost/ reports 35 errors across 6 files. Highlights: _interceptor.py's latency_ms: int parameter receives a float; frameworks/flask.py:33's self._handle = None is inferred as None and breaks reassignment; stale # type: ignore comments flagged as unused.
Fix: decide on the actual mypy contract. Either run mypy in CI and fix the 35 errors, or drop the strict-clean claim from the README. The first option is correct.
Files: recost/_interceptor.py, recost/frameworks/flask.py, others; README.md; pyproject.toml; CI workflow.
Includes: original #2.
Body:
recost/_init.py registers no os.register_at_fork hooks. After a fork:
- Patched method-object references are inherited (fine — class attrs).
- The module-level
_handleglobal points at the parent'sRecostHandle, but no timer thread is running in the child. Flushes never fire. Transport._local._loopreferences the parent's asyncio loop, which doesn't exist in the child.run_coroutine_threadsaferaises and is swallowed (_transport.py:195-197). All events queue silently and never send.
Symptom: every gunicorn pre-fork or Celery worker started after init() collects metrics into the void. Most production Python deployments use prefork servers — this is the single largest production gap.
Fix: register os.register_at_fork(after_in_child=_reinit_after_fork) that re-creates the timer thread and transport thread in the child. Alternatively, document a "call init() in the worker's post_fork hook" pattern and refuse to instrument until init() has been called in the current PID.
Files: recost/_init.py, recost/_transport.py, README.md, tests/test_init.py
Includes: original #16.
Body:
Multiple module globals are read/written without locks:
-
_init.py:78, 225reads/writes_handlewithout a lock. Two threads racinginit()can both pass theif _handle is not Noneguard, both callinstall(), and orphan the first thread's transport + aggregator (the background timer thread leaks). -
Independent of (1), the patches and the callback registration can desynchronize. Thread A in
dispose()is mid-_unpatch_*while Thread B ininit()callsinstall(on_event)and sees_installed=True(set by A but not yet cleared). B short-circuits — the new init has no patches.
Fix: guard init(), dispose(), install(), and uninstall() with a single module-level threading.RLock. Add tests that race both pairs.
Files: recost/_init.py, recost/_interceptor.py, tests/test_init.py
Includes: original #3, #20.
Body:
Brand name and class names are used inconsistently:
| Where | Spelling |
|---|---|
README.md:1, 3 |
Recost |
README.md Flask section + license |
ReCost |
recost/__init__.py:1-5 docstring |
ReCost |
recost/frameworks/flask.py:22 (class) |
ReCost |
recost/frameworks/fastapi.py (class) |
RecostMiddleware |
CLAUDE.md:19, 27 |
EcoAPIHandle, EcoAPIConfig, EcoAPIMiddleware — none exist in code |
The FastAPI middleware is RecostMiddleware, the Flask extension is ReCost — different conventions for analogous classes.
Stale doc claims piled on top:
- "21+ built-in rules" in both docs; actual count is 34 rules / 14 providers (
_provider_registry.py:33comment already says so). README.md:106documentsflush_interval: float (30.0)as a top-level option; in code,flush_intervalis the deprecated seconds option that emits aDeprecationWarning. The real option isflush_interval_ms: int = 30_000. Users who follow the README pull warnings into their logs.flush_interval_ms,max_buckets,shutdown_flush_timeout_msare missing from the README config table.
Fix:
- Pick one brand spelling (recommend
Recost). - Rename
ReCost(Flask) →RecostExtension(orRecost), with a deprecation alias for one release. - Strip every
EcoAPI*reference fromCLAUDE.md. - Walk the README: update provider count, swap to
flush_interval_msas the documented option (markflush_intervaldeprecated), add the missing config fields. - Add a test that asserts
len(BUILTIN_PROVIDERS) == 34so future drift is caught.
Files: recost/frameworks/flask.py, recost/__init__.py, README.md, CLAUDE.md, tests/test_provider_registry.py
Includes: original #4, #5, #6.
Body:
Two related lifecycle gaps:
-
No
atexit/ signal handler. The flush timer is a daemon thread — when the process exits normally (sys.exit(), end of__main__, AWS Lambda invocation completes, SIGTERM in a container), the daemon thread is killed and the current aggregator bucket is lost. Cron jobs, Lambdas, batch scripts, and one-shot CLIs report nothing unless the user manually callshandle.dispose(). -
Dispose during connect leaks FDs.
recost/_transport.py:133-146, 199-210'sdispose()setsself._running = Falseand queues a sentinel. If the loop is currently insidewebsockets.connect(url)(blocking await on TCP connect), the sentinel sits in the queue until the connect either succeeds or hits the OS TCP timeout (~75s on Linux).thread.join(timeout=2.0)returns without joining. The daemon thread + open socket FD leak until process exit. In long-lived processes that callinit()/dispose()many times (test suites, Flask dev server with reload), FDs accumulate.
Fix:
- In
init(), registeratexit.register(_final_flush)and (optionally)signal.signal(SIGTERM, ...). Make handlers idempotent. Respectshutdown_flush_timeout_ms. Provideauto_shutdown_handlers=Falseopt-out. - On dispose,
loop.call_soon_threadsafe(loop.stop)and cancel pending tasks. Then join with a timeout derived fromshutdown_flush_timeout_ms(currently hardcoded 5s) and log if the thread didn't exit.
Files: recost/_init.py, recost/_transport.py, tests/test_init.py
Includes: original #9 (hardcoded join), #18 (no atexit), #19 (dispose leak).
Body:
Three related local-transport hardening issues:
-
Queue unbounded.
_transport.py:194enqueues outbound messages with no cap. The re-queue path on disconnect (_transport.py:176) also usesput_nowait. A long extension outage = unbounded memory growth. -
Reconnect loops forever.
_transport.py:148-184retrieslocalhost:9847forever (capped at 30s per attempt). A user who deploys with noapi_key(default = local mode) and no VS Code extension running keeps a daemon thread retrying forever while events queue up. Typical misconfig; silent failure. -
No auth on the WS port. The SDK sends serialized
WindowSummarypayloads to whatever process responds first on127.0.0.1:9847. Any local process can squat the port and silently sink all telemetry. Low risk on a dev machine, but easy to harden.
Fix:
- Cap the queue (e.g.
asyncio.Queue(maxsize=1000)) with drop-oldest. Log a single warning on first drop; reset on reconnect. - After N consecutive failed connects (e.g. 10), give up and emit
on_erroronce. Detect "noapi_keyAND first connect failed" and warn loudly. - Lightweight handshake: SDK opens, sends
{"type":"hello","sdk":"recost-py","version":...}; extension replies{"type":"ack"}. On no-ack within N ms, drop the connection without sending payloads. Coordinated change in the VS Code extension repo.
Files: recost/_transport.py, tests/test_transport.py, plus a paired change in the extension.
Includes: original #8, #17, #24.
Body:
Three related body-sizing issues:
-
aiohttp
json=andFormDatareport 0 bytes.recost/_interceptor.py:317-325checks only thedata=kwarg. The most common aiohttp POST patterns (session.post(url, json={...}),session.post(url, data=FormData(...)), async-iterable bodies,BytesIO) all fall through and report 0. -
httpx streaming body silently materialized.
_interceptor.py:198-201, 244-247accessesrequest.contentto compute body size. For ordinary requests built via client methods,contentis bytes — fine. For users passing a custom streaming body (httpx.Request("POST", url, content=async_iterator)), accessingcontentreads and buffers the entire iterator. A large upload silently OOMs the process. -
Response body size derived from
Content-Lengthonly. Chunked / streaming responses (LLM SSE streams) don't set the header; they always reportresponse_bytes=0. The README's "response body size (bytes)" promise is partially false for streams.
Fix:
- For aiohttp: when
json=is present, JSON-serialize and measure; forFormData, query its_size; for unknown body types, leave at 0 but document. - For httpx:
isinstance(request.content, bytes)check before reading. Non-bytes → skip size measurement. - For responses: document the streaming caveat in README; optionally tee non-streaming response bodies.
Files: recost/_interceptor.py, README.md, tests/test_interceptor.py
Includes: original #15 (Content-Length), #21 (aiohttp json), #22 (httpx streaming).
Body:
Four explicit claims in the README have no test coverage:
- aiohttp interceptor branch.
_interceptor.pypatchesaiohttp.ClientSession._request, buttests/test_interceptor.pyonly covers urllib3, httpx sync, and httpx async. Zero direct aiohttp tests. - No headers/bodies captured (privacy contract). Add a test that asserts
RawEvent-shaped output carries no dict-typed payload field, plus a test that issues a request with sensitive headers and confirms they don't surface. - No self-instrumentation. Verify that
urllib.requestcalls made from_post_clouddo not trigger the urllib3 patch. - 5xx retry path.
test_no_retry_on_4xxexists but no positive test thatmax_retriesactually runsnattempts with exponential backoff.
Also missing: deprecation-warning test for flush_interval, would_overflow early-flush path test, handle.dispose() actually stops new flushes, _LocalTransport graceful no-op when websockets missing, Flask graceful degradation when flask is missing.
Fix: add tests for each. The aiohttp tests should mirror the existing httpx async tests (success, 4xx capture, latency, response-bytes, double-count guard via reentrancy).
Files: tests/test_interceptor.py, tests/test_transport.py, tests/test_init.py, tests/test_flask.py
Includes: original #7, #14.
Body:
recost/_init.py:194-195 catches every exception in the flush loop and continues firing every flush_interval_ms. A deterministic bug (malformed metric, transport never reachable) silently re-fires forever, logging every cycle.
Fix: track consecutive failures; after N (e.g. 5), back off exponentially up to a ceiling, and surface via on_error.
Files: recost/_init.py, tests/test_init.py
Includes: original #10.
Body:
Two related urllib3-patch maintenance issues:
-
_interceptor.py:108listspool_connectionsandpool_maxsizeas kwargs on theurlopenwrapper. Neither is an actualHTTPConnectionPool.urlopenparameter — they'rePoolManager.__init__parameters. The wrapper accepts and silently drops them. -
_interceptor.py:108referencesurllib3.util.Timeout.DEFAULT_TIMEOUTas a default-argument value at function-definition time. If the installed urllib3 (or a future release) doesn't expose that attribute, the SDK fails to import — and the failure happens before any user code runs.
Fix:
- Remove the dead
pool_connections/pool_maxsizeparams; rely entirely on**kwargsforwarding. - Use a sentinel (
_DEFAULT = object()) for the timeout default; resolve to the urllib3 default inside the body. Wrap import-time lookups in a try/except so a broken urllib3 install degrades to a no-op rather than crashing the host app.
Files: recost/_interceptor.py, tests/test_interceptor.py
Includes: original #11, #23.
Body:
_init.py:148-150 uses pattern in event.url or pattern in event.host. Short or hostname-like patterns over-match. * is taken literally, not as a glob — users naturally pass "*.internal.corp" expecting it to work and silently miss every request.
Also: when api_key is set (cloud mode), the SDK does not auto-exclude localhost / 127.0.0.1. A local dev recost instance could be self-traced.
Fix:
- Add an option for exact host match (e.g. accept
("=", "api.example.com")tuples or a separateexclude_hostsfield). - Document the substring contract explicitly; reject patterns containing
*with a clear error so users don't misuse it. - Auto-exclude localhost when a local recost dev API is detected.
Files: recost/_init.py, README.md, tests/test_init.py
Includes: original #12.
Body:
Two trivial fixes:
ruff checkreportsF401 unused-importforMAX_BUCKETSinrecost/_transport.py:23.recost/_interceptor.py:148-156, 219-228, 266-275, 342-350re-raise viaraise excrather than bareraise, rebinding the traceback. End users debugging SDK-wrapped errors see the SDK wrapper at the top of the stack, not their own call site.
Fix:
- Remove the unused import.
- Replace every
raise excwith bareraise.
Files: recost/_transport.py, recost/_interceptor.py
Includes: original #13, #25.
- Open issues 1–13 in the Python repo.
- Label by priority (
P0/P1/P2) and kind (bug/docs/test/runtime). - Group milestones:
- Next patch release: issues 1, 2, 3 — the three P0s. Issue 3 (fork-safety) is the single largest production gap; most Python deployments use prefork servers.
- Next minor release: issues 4, 5, 6, 7, 8, 9 — P1 cluster (state safety, docs, lifecycle, transport, body sizing, test gaps).
- Backlog: 10–13.
- Link 4, 6 — both touch the lifecycle / threading model; consider one consolidated PR.
- Link 5 (naming + docs) — pure documentation PR, easy to land first and unblock everything else.
- Link 7 — coordinated change with the VS Code extension repo for the handshake.
This consolidation collapses 25 raw findings into 13 issues by grouping by fix location and shared rationale. Mapping:
| Consolidated | Original |
|---|---|
| 1 | #1 (aggregator race) |
| 2 | #2 (mypy errors) |
| 3 | #16 (fork-safety) |
| 4 | #3 (_handle race), #20 (init/dispose race) |
| 5 | #4 (naming), #5 (flush_interval deprecation), #6 (provider count) |
| 6 | #9 (hardcoded timer join), #18 (no atexit), #19 (dispose-during-connect leak) |
| 7 | #8 (queue unbounded), #17 (infinite reconnect), #24 (no auth) |
| 8 | #15 (Content-Length), #21 (aiohttp json=), #22 (httpx streaming) |
| 9 | #7 (aiohttp tests), #14 (privacy/self/5xx) |
| 10 | #10 (flush-loop errors) |
| 11 | #11 (dead kwargs), #23 (Timeout import-time) |
| 12 | #12 (exclude pattern) |
| 13 | #13 (ruff F401), #25 (raise exc) |