Skip to content

Reduce StripeClient() cold start latency for serverless environments#1834

Open
jar-stripe wants to merge 5 commits into
masterfrom
jar/lazy-imports
Open

Reduce StripeClient() cold start latency for serverless environments#1834
jar-stripe wants to merge 5 commits into
masterfrom
jar/lazy-imports

Conversation

@jar-stripe

@jar-stripe jar-stripe commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

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 inside StripeClient.__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 including re, enum, and platform, adding 300-700ms at Lambda-scale CPU.

What?

  • Move HTTP client library detection to module load time. The available client class is resolved once when stripe._http_client is first imported, so new_default_http_client() and new_http_client_async_fallback() just instantiate the pre-resolved class without any imports.
  • Replace uuid.uuid4() with os.urandom(16).hex() for idempotency key generation. Produces an equivalent 32-char cryptographically random hex string without importing the uuid module. os is already imported in _api_requestor.py.

See Also

Changelog

  • Moves HTTP library imports to module load time to better accommodate AWS Lambda and other serverless environments that have separate Init phase and Invoke phase time budgets.

jar-stripe and others added 4 commits June 22, 2026 15:53
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
@jar-stripe

jar-stripe commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Benchmark Results

Methodology: Each measurement is a separate Docker container (python:3.11-slim) with --cpus=0.1 (approximating a 128-256MB AWS Lambda). Each container does a fresh pip install -e . then runs a single cold-start measurement of import stripe and stripe.StripeClient('sk_test_fake'). 10 iterations per configuration.

Baseline (master)

import stripe: 1.100s  StripeClient(): 2.495s  Total: 3.595s
import stripe: 0.608s  StripeClient(): 0.989s  Total: 1.597s
import stripe: 1.195s  StripeClient(): 1.905s  Total: 3.101s
import stripe: 1.294s  StripeClient(): 2.307s  Total: 3.601s
import stripe: 1.490s  StripeClient(): 2.003s  Total: 3.493s
import stripe: 0.911s  StripeClient(): 1.585s  Total: 2.495s
import stripe: 1.296s  StripeClient(): 2.298s  Total: 3.593s
import stripe: 0.706s  StripeClient(): 2.193s  Total: 2.898s
import stripe: 1.499s  StripeClient(): 2.203s  Total: 3.702s
import stripe: 1.004s  StripeClient(): 2.501s  Total: 3.505s

Mean: import stripe=1.110s  StripeClient()=2.048s  Total=3.158s

Fixed (this PR)

import stripe: 1.705s  StripeClient(): 0.105s  Total: 1.809s
import stripe: 3.893s  StripeClient(): 0.108s  Total: 4.002s
import stripe: 2.797s  StripeClient(): 0.206s  Total: 3.003s
import stripe: 2.698s  StripeClient(): 0.099s  Total: 2.797s
import stripe: 2.099s  StripeClient(): 0.114s  Total: 2.214s
import stripe: 2.904s  StripeClient(): 0.108s  Total: 3.012s
import stripe: 2.705s  StripeClient(): 0.205s  Total: 2.910s
import stripe: 3.098s  StripeClient(): 0.111s  Total: 3.208s
import stripe: 3.009s  StripeClient(): 0.194s  Total: 3.203s
import stripe: 2.800s  StripeClient(): 0.205s  Total: 3.005s

Mean: import stripe=2.771s  StripeClient()=0.145s  Total=2.916s

Summary

Metric Baseline Fixed Delta
import stripe 1.110s 2.771s +1.661s (moved to init phase, 10s budget)
StripeClient() 2.048s 0.145s -1.903s (invoke phase, 3s budget)
Total 3.158s 2.916s -0.242s

The key result: StripeClient() drops from ~2s to ~0.15s. The import cost for HTTP libraries such as requests shifts to import stripe, which is more friendly to environments where import stripe may be run in a different phase than StripeClient(...).

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
@jar-stripe jar-stripe marked this pull request as ready for review June 23, 2026 15:36
@jar-stripe jar-stripe requested a review from a team as a code owner June 23, 2026 15:36
@jar-stripe jar-stripe requested review from justiny-stripe and xavdid and removed request for a team June 23, 2026 15:36

@xavdid xavdid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few thoughts!

Comment thread stripe/_api_requestor.py
# 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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread stripe/_http_client.py
return NoImportFoundAsyncClient


_default_sync_client = _resolve_sync_client()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread stripe/_http_client.py


def _resolve_sync_client():
try:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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') # None

So 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 = _lib

This 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).

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