Skip to content

Cache the flow private key instead of parsing it on every request - #219

Merged
david-lev merged 1 commit into
david-lev:devfrom
ashbrener:cache-flow-private-key
Aug 2, 2026
Merged

Cache the flow private key instead of parsing it on every request#219
david-lev merged 1 commit into
david-lev:devfrom
ashbrener:cache-flow-private-key

Conversation

@ashbrener

Copy link
Copy Markdown
Contributor

The problem

default_flow_request_decryptor calls load_pem_private_key on every flow data exchange. Parsing a PEM is expensive — cryptography validates the RSA key material on load — and it turns out to dominate the cost of the decryption it exists to enable.

Measured on master, 2048-bit key, 30 requests each, averaged:

key before after
unencrypted 56.14 ms 2.81 ms 20.0×
password-protected 56.68 ms 2.79 ms 20.3×

Worth noting it is not the password KDF — an unencrypted key costs the same. It affects every flow endpoint.

In practice that is a ceiling of roughly 18 exchanges per second per core, in synchronous CPU work on the event loop, spent re-answering a question whose answer never changes for the life of the process. We found it while load-testing a flow: every screen tap carried ~56ms that had nothing to do with the screen, and it was by far the largest cost in the journey.

The change

The key is parsed once per (private_key, password) pair, behind a bounded functools.lru_cache. Nothing else in the function changes — same OAEP/SHA-256 unwrap, same AES-GCM, same return contract.

That takes the same request to ~2.8 ms, and the ceiling to nearer 355/s/core.

Notes

  • Bounded (maxsize=8), and its keys come from the developer's own configuration — never from a request — so nothing network-facing can grow it.
  • No new exposure: the key material is already passed into this function on every call and lives in the process's configuration. The cache holds a parsed form of something the caller already has.
  • Callers passing a rotated key get a fresh parse, since the PEM string is part of the cache key.
  • One test added, in the existing style of tests/test_server.py: the key is parsed once, and the password is part of the cache key so a wrong password is not served a key a correct one loaded.

tests/test_server.py passes (4/4). The 5 failures and 14 errors elsewhere in the suite are pre-existing on master — identical before and after this change.

default_flow_request_decryptor called load_pem_private_key on every
flow data exchange. Parsing a PEM is expensive — cryptography validates
the RSA key material on load — and it dominated the cost of the
decryption it exists to enable: ~56ms against ~2.8ms for the rest of
the request, with or without a password on the key.

That put a ceiling of roughly 18 exchanges per second per core on any
flow endpoint, in synchronous CPU work, to re-answer a question whose
answer never changes for the life of the process.

The key is now parsed once per (private_key, password) pair through a
bounded lru_cache, taking the same request to ~2.8ms — about 20x, and a
ceiling nearer 355/s/core. The cache keys come from the developer's own
configuration and never from a request.
@david-lev
david-lev changed the base branch from master to dev August 2, 2026 20:03
@david-lev

Copy link
Copy Markdown
Owner

Thanks so much for this contribution — really appreciate the effort you put in. Merged into dev! 🙌

@david-lev david-lev closed this Aug 2, 2026
@david-lev david-lev reopened this Aug 2, 2026
@david-lev
david-lev merged commit ae352a2 into david-lev:dev Aug 2, 2026
16 of 18 checks 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.

2 participants