Skip to content

fix(http): shared aiohttp session, headers mutation, store.delete params#82

Closed
SiddarthAA wants to merge 1 commit intoJigsawStack:mainfrom
SiddarthAA:main
Closed

fix(http): shared aiohttp session, headers mutation, store.delete params#82
SiddarthAA wants to merge 1 commit intoJigsawStack:mainfrom
SiddarthAA:main

Conversation

@SiddarthAA
Copy link
Copy Markdown

Overview

Three fixes to the HTTP layer — two correctness bugs and one performance issue. All changes are backwards-compatible with no API surface changes.


Changes

1. perf(async_request) — Replace per-request ClientSession with a shared module-level session

File: async_request.py

Every async request was spinning up a brand-new aiohttp.ClientSession(), which aiohttp explicitly documents as an antipattern. Each session tears down its TCP connection on completion, meaning every API call was paying a full handshake cost with zero connection pooling.

Introduced a module-level _shared_session with a lazy _get_shared_session() initializer. The session is created once and reused for the process lifetime; if it's been externally closed, the helper recreates it automatically. All perform_* methods now go through this instead of managing their own sessions.


2. fix(request, async_request)__get_headers was permanently mutating the caller's config dict

Files: request.py, async_request.py

Both Request and AsyncRequest held a direct reference to the headers dict from the caller's config — no copy, just self.headers = config.get("headers"). During multipart file-upload requests, __get_headers() called self.headers.pop("Content-Type") to let requests/aiohttp set the multipart boundary themselves.

The problem: that pop was operating on the original config dict. Every subsequent request reusing the same config — which is exactly what all service classes do — would silently go out missing Content-Type, causing failures that are genuinely hard to trace back to the source.

Fix applied at two levels:

  • __init__ now does dict(raw_headers) so self.headers is always an owned copy
  • __get_headers() makes a second local copy before calling .pop(), so neither self.headers nor the original config are ever touched

3. fix(store)Store.delete was passing the key string as params

File: store.py

Both sync and async delete(key) were calling the underlying request with params=key, where key is a plain string. The key is already encoded in the URL path (/store/file/read/{key}) — it has no business being in params, and passing a bare string instead of a dict is undefined behaviour depending on how the HTTP client decides to interpret it.

Changed params=keyparams={} in both Store.delete and AsyncStore.delete.


Tests

8 unit tests added, all runnable locally without an API key:

# Coverage
1 Sync client: original config headers dict is not mutated by a multipart call
2 Async client: same
3 Multipart outgoing headers correctly omit Content-Type (lets the client set the boundary)
4 _get_shared_session() returns the same ClientSession instance on repeated calls
5 Store.delete sends params={}
6 AsyncStore.delete sends params={}
7 Plain JSON requests still attach Content-Type: application/json correctly
8 Repeated multipart calls against the same config don't degrade

Contact

Thanks for reviewing — happy to adjust anything or split into separate PRs if preferred :D
siddartha_ay@protonmail.com | (everything about me at siddartha-ay.xyz )

Three correctness and performance bugs fixed across the HTTP layer:

1. perf(async_request): replace per-request aiohttp.ClientSession with a
   module-level shared session (_get_shared_session).

   Previously every AsyncRequest method created a brand-new
   aiohttp.ClientSession per call, bypassing connection pooling entirely and
   incurring TCP handshake overhead on every API request. The new helper
   lazily creates one module-level session and reuses it for the lifetime of
   the process, matching the pattern explicitly recommended by aiohttp:
   https://docs.aiohttp.org/en/stable/client_quickstart.html\#make-a-request

2. fix(request, async_request): prevent __get_headers from mutating the
   caller's headers dict.

   Both Request and AsyncRequest stored a direct reference to the headers
   dict from the config TypedDict and called .pop('Content-Type') on it
   during multipart file-upload requests. This permanently removed the key
   from the shared config dict, so any subsequent request using the same
   client config would silently lose its Content-Type header. The fix:
   - __init__ now copies the incoming headers into self.headers so the
     original config is never touched.
   - __get_headers builds its own fresh dict and strips Content-Type from
     a local copy of self.headers, not from self.headers itself.

3. fix(store): Store.delete and AsyncStore.delete passed params=key instead
   of params={}.

   The key was already encoded in the URL path
   (/store/file/read/{key}). Passing the raw key string as the params
   argument could corrupt the DELETE request. Both sync and async delete
   methods now correctly use params={}.
@Khurdhula-Harshavardhan
Copy link
Copy Markdown
Collaborator

  • claim 1's fix breaks on the second asyncio.run() call, session is bound to a dead loop
  • the .closed check returns False so _get_shared_session hands back the stale one
  • not fork-safe either, and you'll get "Unclosed client session" warnings at shutdown
  • better to scope the session to the client with aenter/aclose than a module singleton
  • claim 2 is a real bug and the fix looks right. happy to take this one on its own
  • claim 3 is fine but DELETE has no body, so "corrupt the request body" isn't accurate
  • PR body lists 8 tests with a table but no test files show up in the diff. what happened here? do not believe this to be right.
  • could you split this? headers + store.delete in one PR, the session change separately

Please clean up the PR description to write updates by hand if possible or optimized by ai. Easier to work on this than an much longer AI-generated doc :) thanks.

@SiddarthAA
Copy link
Copy Markdown
Author

SiddarthAA commented Apr 24, 2026

Thank for the detailed review @Khurdhula-Harshavardhan :D

Agreed on splitting this up, I'll move the confirmed bug (claim 2) and the header/delete changes into a separate, minimal PR (with tests)!

I’ll drop the session lifecycle changes from this PR and revisit them deeply since they need a more careful design (especially around event loop safety and reuse, sounds painful :p)

Also fixing the DELETE wording and cleaning up the PR description to reflect only what’s actually in the diff.

Will update this ASAP! thankyou!

@SiddarthAA
Copy link
Copy Markdown
Author

  • claim 1's fix breaks on the second asyncio.run() call, session is bound to a dead loop
  • the .closed check returns False so _get_shared_session hands back the stale one
  • not fork-safe either, and you'll get "Unclosed client session" warnings at shutdown
  • better to scope the session to the client with aenter/aclose than a module singleton
  • claim 2 is a real bug and the fix looks right. happy to take this one on its own
  • claim 3 is fine but DELETE has no body, so "corrupt the request body" isn't accurate
  • PR body lists 8 tests with a table but no test files show up in the diff. what happened here? do not believe this to be right.
  • could you split this? headers + store.delete in one PR, the session change separately

Please clean up the PR description to write updates by hand if possible or optimized by ai. Easier to work on this than an much longer AI-generated doc :) thanks.

Thanks again for the detailed review @Khurdhula-Harshavardhan — made me dig deeper into the codebase :p

I’ve split out the confirmed fixes into smaller, focused PRs to make review easier.
The headers/config mutation fix is now tracked separately here: #83

I’m closing this PR to keep things clean and easier to reason about. I’ll follow up with a separate PR for the session lifecycle changes after revisiting the design in abit more detailed manner!

Appreciate the guidance — would love to make alot more contributions!
Siddartha A Y | siddartha-ay.xyz

@SiddarthAA SiddarthAA closed this Apr 24, 2026
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.

2 participants