Skip to content

perf(api): trim Python overhead on the plain decode fast path (#122) - #128

Merged
ZhuchkaTriplesix merged 2 commits into
devfrom
issue/122-python-fast-path-overhead
Sep 28, 2026
Merged

ZhuchkaTriplesix merged 2 commits into
devfrom
issue/122-python-fast-path-overhead

Conversation

@ZhuchkaTriplesix

Copy link
Copy Markdown
Member

Problem

For HS256 the Python wrapper cost about as much as the native decode call itself:

µs/call
_oxyjwt.decode (native) 1.30
oxyjwt.decode (fast path) 2.14

The fast path in PyJWT.decode/decode_complete called _is_plain_decode (a 9-argument function) and _require_detached_payload_for_rfc7797 (a keyword-argument call), and _validate_claims_default always called time.time() and re-checked exp even though Rust had already validated it (leeway is always 0 on this path).

Fix

  • Inline the _is_plain_decode condition and the RFC 7797 detached_payload pre-check directly into decode()/decode_complete(), removing both function calls from the hot path. _require_detached_payload_for_rfc7797 stays as a function for the general (non-fast) path, which still needs its full signature.
  • In _validate_claims_default, only re-check exp in Python when it is not a plain int. Rust already enforces exp > now with an integer clock on this path, which is the exact same predicate as the truncating check below for an integer exp — redoing it only cost a time.time() call for no behavioral difference. A float exp still gets the Python recheck: Rust rounds a fractional value to the nearest second, while PyJWT (and this check, for parity) truncates it, so the two can disagree right at the boundary.
  • time.time() is now only called when iat or a non-int exp is present.

No behavioral change.

Results

HS256, prebuilt token, release build:

before after
wrapper overhead over native, iat+int exp present ~0.85 µs ~0.57 µs (−33%)
wrapper overhead over native, no time claims ~0.85 µs ~0.37 µs (−57%)

Tests

Added test_float_exp_boundary_still_checked_in_python (tests/test_decode_fast_path.py): pins exp to K + 0.6 for the current whole second K. Rust's own rounding-based boundary check alone would round that up to K + 1 and accept it as not-yet-expired; the Python recheck must still truncate it back to K and reject it, proving the float recheck still runs after this change. Confirmed non-flaky over repeated runs (the boundary logic doesn't depend on landing near a wall-clock second edge).

Checklist

  • HS256 oxyjwt.decode overhead over native reduced by ≥ 30% (measured 33–57%, see above)
  • tests/test_decode_fast_path.py, tests/test_parity_pyjwt.py green
  • Added a test for float exp boundary parity
  • Full pytest (259 passed, 1 skipped) and mypy clean
  • No behavioral change

Closes #122.

For HS256 the Python wrapper cost about as much as the native decode
call itself (0.85us overhead on top of ~1.30us native). The fast path
in PyJWT.decode / decode_complete called _is_plain_decode (a
9-argument function) and _require_detached_payload_for_rfc7797 (a
keyword-argument call), and _validate_claims_default always called
time.time() and re-checked exp even though Rust had already validated
it with leeway=0.

- Inline the _is_plain_decode condition and the RFC 7797
  detached_payload pre-check directly into decode() and
  decode_complete(), removing the two function calls from the hot
  path. _require_detached_payload_for_rfc7797 stays as a function for
  the general (non-fast) path, which still needs its full signature.
- In _validate_claims_default, only re-check exp in Python when it is
  not a plain int. Rust already enforces exp > now with an integer
  clock on this path, which is the exact same predicate as the
  truncating check below for an integer exp. A float exp still needs
  the Python recheck: Rust rounds a fractional value to the nearest
  second while PyJWT (and this check, for parity) truncates it, so the
  two can disagree right at the boundary.
- time.time() is now only called when iat or a non-int exp is present.

Measured (HS256, prebuilt token): wrapper overhead over the native
call dropped from ~0.85us to ~0.57us with iat+int exp present (~33%
less), and to ~0.37us with no time claims at all (~57% less).

No behavioural change. Added a regression test pinning exp to a
fractional second (K + 0.6 for the current whole second K) to prove
the float recheck still runs: Rust rounds that up and would accept it,
but the Python truncating recheck must still reject it for PyJWT
parity.

Closes #122
Regression test for the exp re-check change in the previous commit:
a float exp pinned to K + 0.6 (K = current whole second) must still
raise ExpiredSignatureError via the Python recheck, even though Rust's
own rounding-based boundary check alone would accept it as not yet
expired.
@ZhuchkaTriplesix
ZhuchkaTriplesix merged commit 0ec4c19 into dev Sep 28, 2026
12 of 13 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.

1 participant