Skip to content

fix(base): reference-count session lifecycle for safe concurrent use - #9

Merged
codemug merged 1 commit into
mainfrom
fix/shared-session-concurrency-race
Jul 11, 2026
Merged

fix(base): reference-count session lifecycle for safe concurrent use#9
codemug merged 1 commit into
mainfrom
fix/shared-session-concurrency-race

Conversation

@codemug

@codemug codemug commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Problem

Under concurrent MCP requests, tools were failing with:

WARNING:aiofmp.base:HTTP client error, attempt 2/4: Connector is closed.
ERROR:aiofmp.company_tools:Error in get_shares_float: 'NoneType' object has no attribute 'get'
ERROR:aiofmp.company_tools:Error in get_company_profile: 'NoneType' object has no attribute 'get'

This was not an FMP subscription/tier issue — a single sequential call to company.profile("AAPL") and company.shares_float("AAPL") returns valid data on the current plan.

Root cause

The MCP server hands every tool the same global singleton client (get_fmp_client()), and every one of the 22 *_tools.py categories wraps its work in async with client: — which calls start() on entry and close() on exit. Under concurrent requests the tools share one aiohttp session, so the first request to finish closed the session out from under the others:

  • await session.close() tears down the connector while another request is mid-flight → Connector is closed (retried, "attempt 2/4").
  • self._session = None, so the retry hits self._session.get(...) on None'NoneType' object has no attribute 'get'.

Reproduced deterministically: one shared client + 12 concurrent async with scopes → 11/12 failed with exactly these errors.

Fix

Reference-count the session lifecycle in FMPBaseClient.start()/close() under an asyncio.Lock: overlapping scopes share a single session, and it is only torn down when the last active scope exits. The reference is counted only after the session is guaranteed to exist, so a failed session creation can't leak a reference.

Because every tool funnels through FMPBaseClient.start/close, this one change fixes all 22 tool categories — no tool code changes.

Verification

Check Result
New regression tests (tests/test_base.py) 3 pass (failed on old code first)
Full suite 931 passed
ruff check clean
Live concurrency repro (direct client) 12/12 ok (was 11/12 failing)
Live MCP server, 12 concurrent get_shares_float/get_company_profile 12/12 success
Raw container logs (rebuilt image) 175 tool calls, 0 "Connector is closed", 0 "NoneType", 0 ERROR/WARNING

🤖 Generated with Claude Code

The MCP server shares one global FMPBaseClient across all in-flight
requests, and every tool wraps its work in `async with client:`, which
calls start() on entry and close() on exit. Under concurrent requests the
tools shared a single aiohttp session, so the first request to exit closed
it out from under the others:

  - `await session.close()` tore down the connector mid-request
    -> "Connector is closed" (then retried)
  - `self._session = None`, so the retry hit `self._session.get(...)`
    on None -> "'NoneType' object has no attribute 'get'"

Reference-count start()/close() under an asyncio.Lock so overlapping
scopes share one session, torn down only when the last active scope exits.
This fixes all 22 tool categories at once since they funnel through
FMPBaseClient.start/close; no tool code changes required.

Add tests/test_base.py covering the overlapping-scope invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codemug
codemug merged commit 21fd091 into main Jul 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant