Reduce StripeClient() cold start latency for serverless environments#1834
Reduce StripeClient() cold start latency for serverless environments#1834jar-stripe wants to merge 5 commits into
Conversation
asyncio was imported at module level in _http_client.py but only used by AIOHTTPClient.sleep_async(). Since _http_client is loaded eagerly by stripe/__init__.py, every user paid ~1.8s on CPU-constrained environments (e.g. Lambda cold starts) for a module they likely never use. Move the import into AIOHTTPClient.__init__ so only users with aiohttp installed incur the cost. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
The uuid module pulls in 20 transitive modules (including re, enum, platform) and takes 300-700ms to import under Lambda-like CPU constraints. Since os is already imported in this file and os.urandom(16).hex() produces an equivalent 32-char hex string with cryptographic randomness, we can drop the uuid dependency entirely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
Instead of detecting and importing HTTP libraries (requests, httpx, etc.) inside StripeClient.__init__, resolve them once at module load time. This shifts the expensive import cost from the invoke phase (3s Lambda timeout) to the init phase (10s budget). The resolved class is stored directly — new_default_http_client() and new_http_client_async_fallback() just instantiate it. Adding a new client means defining the class and adding one entry to the resolution cascade at the bottom of the file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
This reverts commit 8cf83cd.
Benchmark ResultsMethodology: Each measurement is a separate Docker container ( Baseline (master)Fixed (this PR)Summary
The key result: StripeClient() drops from ~2s to ~0.15s. The import cost for HTTP libraries such as requests shifts to |
Update TestImports to call _resolve_sync_client() and _resolve_async_client() directly, since detection now happens at module load time and patching builtins.__import__ after the fact has no effect on the pre-resolved class. Restore the client precedence documentation as a comment block above the resolution cascade. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
| # IKs should be set for all POST requests and v2 delete requests | ||
| if method == "post" or (api_mode == "V2" and method == "delete"): | ||
| headers.setdefault("Idempotency-Key", str(uuid.uuid4())) | ||
| headers.setdefault("Idempotency-Key", os.urandom(16).hex()) |
There was a problem hiding this comment.
Given that we presumably pay the cost to import uuid during import phase not runtime phase, does this change affect the bottom line much?
Also it's a little dumb, but it would be nice if we could re-insert the dashes so it looks like a UUID. It's useful for readability in workbench.
There was a problem hiding this comment.
Technically yes, but I was seeing ~1.8s for importing uuid in a CPU-constrained docker, which is just under 20% of the init phase budget. I think it would be better to keep uuid out if we can here, but I can also see about adding the dashes back in.
| return NoImportFoundAsyncClient | ||
|
|
||
|
|
||
| _default_sync_client = _resolve_sync_client() |
There was a problem hiding this comment.
I had a PR comment saying we should pull these because it wasn't obvious what they were doing- I didn't clock that _resolve_sync_client was being called deliberately at import time. So a comment might be good here!
|
|
||
|
|
||
| def _resolve_sync_client(): | ||
| try: |
There was a problem hiding this comment.
I was digging into this a little because I was worried that reintroducing this import chain to the hot path would undo the problems we were solving in #1427. Those users and the lambda one have sort of opposite goals- a hot reloading django server wants to defer imports as much as possible and lambda wants to frontload as much as it can. So we want to be careful to balance the needs of both users.
I think the httplib checks are cheap enough that putting them back in the hot path isn't the end of the world. But, we might be able to have our cake and eat it too!
There's a performance cost to using try/except as control flow and I believe import misses aren't free either. But, we can check if we could import a module without actually importing it, which is very fast (and i'm not sure why I spaced this approach before):
from importlib.util import find_spec
find_spec('requests') # ModuleSpec(name='requests', loader=<...
find_spec('missing') # NoneSo I think the fix here is to rewrite this function (and the async one) to check importability of libraries and then return the correct class (to be instantiated as needed).
The downside to this is that inside the clients themselves, they re-import their respective libraries:
class RequestsClient(HTTPClient):
name = "requests"
def __init__(
self,
...
_lib=None, # used for internal unit testing
):
...
if _lib is None:
import requests
_lib = requests
self.requests = _libThis is ~free when we've already successfully run a import requests (which was used to determine that we should use this library) but would get called at runtime if we used find_spec to pick a client instead (slowing down the lambda again).
So! I would maybe use find_spec to pick a client (faster for everyone) and then call import <...> to warm up the import cache (slower for django). This is probably close enough to a good balance, but we'll want to listen closely (or understand the performance regression and decide if it's acceptible).
The other option is to control this behavior with an env var, since the two groups want opposite behavior and you can't otherwise change the behavior of import stripe. They're easy to set in lambda, so we could only do the eager import <...> if STRIPE_PYTHON_EAGER_IMPORT is set (which you'd probably only use in lambda).
Why?
Users in serverless environments (AWS Lambda with 128MB memory / 3s timeout) report that
stripe.StripeClient()initialization alone can exceed their invocation timeout budget. A major contributor to this is the fact that HTTP client library imports (requests,httpx) happen insideStripeClient.__init__(), putting their cost in the invoke phase (3s budget) rather than the module init phase (10s budget).Additionally,
uuid(used only for generating idempotency keys) pulls in 20 transitive modules includingre,enum, andplatform, adding 300-700ms at Lambda-scale CPU.What?
stripe._http_clientis first imported, sonew_default_http_client()andnew_http_client_async_fallback()just instantiate the pre-resolved class without any imports.uuid.uuid4()withos.urandom(16).hex()for idempotency key generation. Produces an equivalent 32-char cryptographically random hex string without importing theuuidmodule.osis already imported in_api_requestor.py.See Also
Changelog