Skip to content

Fix/auth flow transient recovery - #145

Merged
btravisebsco merged 8 commits into
masterfrom
fix/auth-flow-transient-recovery
Aug 17, 2026
Merged

Fix/auth flow transient recovery#145
btravisebsco merged 8 commits into
masterfrom
fix/auth-flow-transient-recovery

Conversation

@btravisebsco

@btravisebsco btravisebsco commented Aug 17, 2026

Copy link
Copy Markdown

Description

Fixes a set of correctness bugs in FolioAuth and makes the retry paths actually
effective at recovering from the transient failures FOLIO produces in practice.

The headline item is that folioclient cannot authenticate at all on Python 3.10, a
version we claim to support: FOLIO returns accessTokenExpiration with a trailing Z
(verified against live Okapi and Eureka instances, e.g. 2026-08-07T18:13:36Z), and
datetime.fromisoformat cannot parse that before 3.11, so FolioAuth.__init__ raised
ValueError.

Focused commits, each independently reviewable:

Commit Change
b519434 Parse expirations with dateutil.isoparse — unbreaks Python 3.10
3f0aee6 Read the 401 retry body before raising, so exc.response.text works
b5bce6e Serialize async auth with a per-loop asyncio.Lock
f3dd119 Treat a 401 as authoritative when deciding to re-authenticate
6bddc05 Make 403 retries effective: replayability + a short delay
9445702 Enforce in tests that the sync lock is never acquired reentrantly
43611bb Bump version to 1.0.12
a3bdf0c Document the authentication-flow 403 retry delay

Why each matters

Async authentication was never serialized. The async flow guarded its refresh with a
threading.RLock, which is reentrant per thread. Every coroutine on an event loop
shares one thread, so when one held it across an await the next re-acquired it
immediately — 10 concurrent flows produced 10 logins. Switching to a plain
threading.Lock is worse: it blocks the loop's own thread, which then cannot resume the
holder, deadlocking with no traceback. Now uses one asyncio.Lock per event loop, kept
in a WeakKeyDictionary. FolioClient.async_login had the same bug and is fixed too.

A 401 was not treated as authoritative. The handler refreshed only if
_token_is_expiring() agreed, but a token can be rejected while still looking locally
valid — clock skew, Keycloak key rotation, revocation, a module with a stale JWKS cache.
In all of those the old code replayed the identical request, got a second 401, and
raised. De-duplication is now by token identity, which keeps the "N callers, one login"
property without ever suppressing a needed refresh.

403 retries were ineffective, and 403 is the transient channel. FOLIO's Keycloak
integration currently collapses every backend error that is not a 200/401/403 into a 403,
so a 403 is frequently a masked timeout/502/503 rather than a real permission denial.
Two problems: multipart uploads silently lost their retry (httpx only sets
request._content for in-memory bodies, but MultipartStream is replayable because it
seeks each field back to 0), and the retry was immediate, which rarely outlasts a blip.
Now multipart is retried and there is a short configurable delay.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

No public API changes. Three behavior changes worth a reviewer's attention:

  1. A 401 now triggers re-authentication in cases where it previously did not.
  2. A 403 retry now waits FOLIOCLIENT_FORBIDDEN_RETRY_DELAY seconds (default 1.0)
    before replaying. Set it to 0 to restore the previous immediate replay. A genuine
    permission denial pays this too, since the two are indistinguishable until FOLIO
    passes real status codes through.
  3. Multipart (files=) requests are now retried on 401/403 where they previously were
    not.

Testing

  • Unit tests pass — 271 passing, up from 225 (+46 tests)
  • Integration tests pass (if applicable)
  • New tests added for new functionality

Integration matrix run against all seven server configs: 5 failed, 186 passed, 38 skipped, 24 errors. Every failure and error is the snapshot-2-eureka config,
which has a single root cause — httpx.ConnectError: [Errno 8] nodename nor servname provided — because folio-etesting-snapshot2-kong.ci.folio.org no longer resolves.
An identical run on master produces the identical set of failing test IDs, so nothing
here is a regression. snapshot, snapshot2, eureka, eureka-ecs, bugfest and
bugfest-next all pass, including the folio_post/folio_put/folio_delete user
lifecycle test.

test_get_large_dataset_id_offset was deselected: it ignores the server_config
fixture and hardcodes BUGFEST_CONFIG, so --integration-server cannot filter it, and
it pages 1.5M instances. Worth fixing separately.

Every behavioral claim was mutation-tested. For the three riskiest commits we
deliberately reintroduced each regression — RLock in the async flow, a single cached
lock with and without rebuild-on-loop-change, sharing the sync lock as the registry
guard, expiry-gated 401, unconditional 401 re-auth, _content-only replayability,
immediate 403 replay, replayability-checked-before-refresh, and a nested lock acquire —
and confirmed each fails the test written for it. Without that, the async and multipart
tests in particular would have passed against broken code.

Checklist

  • Code follows the project's style guidelines (ruff check and ruff format clean)
  • Self-review of code completed
  • Code is commented, particularly in hard-to-understand areas
  • Corresponding changes to documentation have been made
  • Changes generate no new warnings

docs/retry_configuration.md gains an "Authentication Flow Retries" section covering the
one new environment variable, FOLIOCLIENT_FORBIDDEN_RETRY_DELAY, plus a troubleshooting
entry for the most likely surprise (403 responses taking about a second longer). The page
now also distinguishes the two retry layers, correcting an existing claim that no retries
happen by default — the authentication flow has always made one automatic 401/403 attempt
regardless of configuration, and only the decorator-based retries are opt-in. Verified
with a clean sphinx-build.

docs/authentication.md needed no change: it already stated that the flow re-authenticates
"whenever it receives a 401", which is what f3dd119 finally makes true. The code was
the thing out of step with the documentation, not the reverse.

No changelog entry was added because the project only records entries at minor/major
boundaries — there are no v1.0.1–v1.0.12 sections.

Notes for reviewers

The most unusual code in this PR is _get_async_lock, so it carries a long docstring
explaining why each alternative was rejected. The short version: asyncio.Lock binds to
a loop the first time it actually has to wait, and that binding is permanent. Because
an uncontended acquire returns before the binding happens, a single cached lock passes
light testing and then fails under real concurrency — including on strictly sequential
reuse, e.g. one client across two asyncio.run() calls or a session-scoped fixture under
pytest-asyncio. Rebuilding one cached lock on loop change is worse still: two live
loops replace each other's lock continuously (measured: 39 rebuilds, 16 distinct lock
objects per loop), destroying exclusion within each loop as well as between them.

self._lock remains an RLock even though nothing nests, because it is held across
network I/O with a possibly unlimited timeout: an accidental nested acquire under a plain
Lock would hang the caller's process, whereas RLock degrades to a redundant login.
That safety net is prevented from hiding real nesting by
test_sync_lock_is_never_acquired_reentrantly rather than by the primitive.

_request_is_replayable imports httpx._multipart.MultipartStream, a private path,
wrapped in try/except ImportError that degrades to "not replayable". It is written as
an allowlist on purpose: if an httpx internal changes, the failure is a skipped retry
rather than a replayed generator or an exhausted file handle silently becoming a
zero-length POST/PUT.

Known follow-ups, deliberately not in scope

  • folio_retry_on_auth_error already retries 403 with a fresh login, but ships disabled
    (FOLIOCLIENT_MAX_AUTH_ERROR_RETRIES=0) and its multiplier=10.0 / exp_base=3.0
    defaults would cost ~100s per permission denial if enabled. Given 403 is the transient
    channel, that combination deserves revisiting.
  • The in-flow 403 retry replays blind while auth_refresh_callback re-logs in. One of
    those is wrong; worth resolving deliberately.
  • _token_is_expiring() still treats an unknown expiry as expiring, so a parse failure
    means one login per request. Left alone because changing it introduces staleness for
    access_token / folio_headers, which have no 401 fallback.
  • _cleanup_folio_auth's del self.folio_auth._token yields AttributeError rather
    than FolioClientClosed after close.
  • Sampling a genuine permission-403 with a low-privilege user would show whether masked
    and real 403s are distinguishable by body shape, which would let the retry be narrowed
    precisely instead of retrying all 403s.

FOLIO's /authn/login-with-expiry returns accessTokenExpiration as ISO-8601
with a trailing 'Z' (verified against live Okapi and Eureka instances, e.g.
'2026-08-07T18:13:36Z'). datetime.fromisoformat cannot parse a 'Z' suffix
before Python 3.11, so on Python 3.10 -- a supported version per
requires-python -- FolioAuth.__init__ raised ValueError and the client could
not authenticate at all.

Parse with dateutil.parser.isoparse instead, which handles 'Z' and offsets
written without a colon on every supported version. python-dateutil is
already a declared dependency.

Also:
- Coerce naive timestamps to UTC so _token_is_expiring never compares naive
  to aware datetimes, which would raise TypeError on every request.
- Degrade unparseable values to None rather than raising, so a future format
  change costs the proactive expiry check rather than all authentication.
- Extract _token_from_response so the sync and async auth methods share one
  parse, replacing two near-identical blocks that each called response.json()
  up to four times.
httpx hands the auth flow an unread response: Client._send_handling_auth only
calls response.read() if the flow yields again, and calls response.close() when
the flow raises. The 401 path raised HTTPStatusError without reading first, so
callers inspecting exception.response.text got ResponseNotRead.

The 403 path already did this (b579e7b); this applies the same fix to 401 and
covers both with tests.

Also change the 403 check from `if` to `elif`. This is not a behavior change --
both branches test the same original response, so they were already mutually
exclusive -- but it makes that explicit so a later edit cannot accidentally run
the 403 retry after the 401 path has already yielded.
The async auth flow guarded its token refresh with a threading.RLock. An RLock is
reentrant per *thread*, and every coroutine on an event loop shares one thread, so
when a coroutine held it across an await the next coroutine re-acquired it
immediately. It provided no mutual exclusion at all: 10 concurrent flows produced
10 logins. Switching to a plain threading.Lock is worse -- it blocks the loop's own
thread, which can then never resume the holder, deadlocking outright with no
traceback.

Use an asyncio.Lock, which suspends the coroutine rather than the thread, and keep
one per event loop in a WeakKeyDictionary:

- asyncio.Lock binds to a loop the first time it must actually wait (see
  _LoopBoundMixin._get_loop), and that binding is permanent. Because an uncontended
  acquire returns before _get_loop() is reached, a single cached lock passes light
  testing and then fails under real concurrency.
- The permanence means even sequential reuse breaks: one cached lock raises "is
  bound to a different event loop" when a client is reused across two asyncio.run()
  calls. That is easy to hit by accident -- asyncio.run() called twice, a
  session-scoped client fixture under pytest-asyncio, a re-run notebook cell.
- Rebuilding a cached lock on loop change fixes that but is worse overall: two live
  loops replace each other's lock continuously (39 rebuilds, 16 distinct lock
  objects per loop), destroying exclusion within each loop as well as between them.
- Weak keys let finished loops drop out so long-lived clients do not accumulate
  locks.

The registry is guarded by its own threading.Lock rather than the existing _lock.
_lock is deliberately held across network I/O by the sync paths, so reusing it here
would let a sync login in a worker thread stall the entire event loop for the
duration of that login -- unbounded when timeout is None.

Also fix FolioClient.async_login, which held _lock across its await and so had the
same absence of exclusion, plus the ability to overwrite a newer token with an
older one.

The residual gap -- sync and async paths using unrelated locks, so two loops or a
loop plus worker threads can each log in once concurrently -- is documented in
_get_async_lock and accepted: assigning self._token is a single atomic attribute
store, and closing the gap would require holding a cross-thread lock across an
await.

Tests cover the core guarantee (concurrent flows share one login), lock stability
within a loop, isolation between concurrent loops, reuse across sequential loops
under contention, registry cleanup, the running-loop precondition, and that the
registry guard cannot be stalled by a sync login. All four ways of regressing this
were verified to fail the intended test.
The 401 handler refreshed only if _token_is_expiring() said the token was old.
That is the wrong predicate: a 401 means the server rejected the token we actually
sent, and a token can be rejected while still looking locally valid -- clock skew,
Keycloak signing-key rotation, session revocation, or a module with a stale JWKS
cache. In all of those the old code replayed the identical request, got a second
401, and raised. The recovery path failed to fire in exactly the cases needing
recovery; the only 401 it recovered from was a race in the last 60 seconds, which
the proactive check would have caught anyway.

De-duplicate on token identity instead. Each flow captures the token it sent, and
on a 401 refreshes unless self._token is no longer that object -- i.e. unless
another thread or coroutine already replaced it, in which case retrying with the
newer token is enough. This keeps the "N callers see a 401, one login happens"
property while never suppressing a genuinely needed refresh.

_token_is_expiring is retained purely as a proactive optimization that saves a
wasted round trip on long-running jobs, and its docstring now says so explicitly.

The captured token is also passed into _set_auth_cookies_on_request rather than
having it re-read self._token, so a concurrent refresh cannot substitute a
different token between the identity check and the cookies actually being applied.

Adds _ensure_sync_token / _ensure_async_token / _require_token, which removes the
duplicated check-and-refresh preamble and lets the two token properties share it.

Tests cover: refresh happens on a 401 even when expiry is far off; refresh is
skipped when the token was concurrently replaced; the retry carries the new
cookies rather than the stale ones; and 8 concurrent threads holding one token
produce exactly one login. Each of the three ways to regress this was verified to
fail the intended test.
Two problems with the 403 retry, which matters because FOLIO's Keycloak
integration currently translates every backend error that is not a 200/401/403
into a 403. In practice a 403 is therefore frequently a masked transient failure
-- a timeout, a 502/503, a reset connection -- rather than a real permission
denial, which makes this the library's primary transient-recovery path rather
than overhead.

1. Multipart uploads were losing the retry. Add _request_is_replayable, which
   admits multipart. httpx only sets request._content for in-memory bodies, so
   testing that alone classifies every files= upload as non-replayable even
   though MultipartStream *is* replayable -- FileField.render_data seeks each
   field back to 0 on every render, and two sends produce byte-identical bodies
   (asserted in test_multipart_bodies_really_do_replay_identically). File
   uploads are exactly where recovering from a masked transient matters most.

   The check is an allowlist on purpose: if an httpx internal changes and we get
   it wrong, we skip a retry we could have done rather than replaying a
   generator (StreamConsumed) or an exhausted file handle, where .read() at EOF
   yields b"" and the retry silently becomes a zero-length POST/PUT.

2. The retry was immediate, which is poorly matched to a masked transient --
   the condition has rarely cleared microseconds later. Wait briefly first,
   tunable via FOLIOCLIENT_FORBIDDEN_RETRY_DELAY (default 1.0s, 0 restores the
   previous behavior). Kept short deliberately: a genuine permission denial is
   indistinguishable today and pays the same cost, and heavier recovery already
   belongs to folio_retry_on_auth_error, which retries 403 with exponential
   backoff and a fresh login. Once FOLIO passes real status codes through, 0
   becomes the better default and this can be retired.

Also, in the 401 branch, refresh before testing replayability. Returning early
without refreshing left the rejected token installed, so every later request
reused a token the server had already rejected -- and with an unknown expiry the
proactive check never fires either, so the client never recovered on its own.

A module-scoped autouse fixture zeroes the delay so unrelated tests stay fast;
the default is asserted separately so the fixture cannot mask a change to it.
Each of the three regressions above was verified to fail the intended test.
FolioAuth._lock is an RLock, but nothing nests and nothing should: every holder
calls only _do_sync_auth, which builds a bare httpx.Client with no auth attached
and so cannot re-enter the flow, and every `yield request` sits outside the locked
region so no caller-supplied code runs while it is held. Instrumenting the lock and
running the full unit and integration suites showed zero reentrant acquisitions.

That makes threading.Lock viable, but RLock is kept deliberately. The lock spans
network I/O with a possibly unlimited timeout, so an accidental nested acquire under
a plain Lock would hang the caller's process with no traceback, whereas under RLock
it degrades to a redundant login. For a client library that is the better failure
mode.

To keep the safety net from silently excusing real nesting, assert the invariant in
tests instead of relying on the primitive: ReentrancyDetectingLock raises on a
nested acquire, and test_sync_lock_is_never_acquired_reentrantly drives the
proactive refresh, both token properties, the 401 re-authentication and the 403
retry through it. test_reentrancy_detector_actually_detects guards against the
detector silently never firing.

Verified by adding a folio_auth_token read inside the locked region, the most
plausible accidental nesting: the new test fails with "acquired reentrantly
(depth 2)", and test_concurrent_threads_401_trigger_one_reauth also fails with 2
logins instead of 1 -- confirming nesting causes real duplicate work rather than
being merely theoretical.

The __init__ comment now records why RLock is used and warns against putting an
access_token / folio_auth_token read inside a locked region.
Adds FOLIOCLIENT_FORBIDDEN_RETRY_DELAY to the retry configuration reference, in
its own "Authentication Flow Retries" section, since it governs the retry FolioAuth
performs internally rather than the tenacity decorators the rest of the page covers.

Explains why a 403 is replayed at all: FOLIO's Keycloak integration currently
translates every backend error that is not a 200/401/403 into a 403, so a 403 is
frequently a masked transient failure rather than a genuine permission denial.
Documents setting it to 0 for deployments where 403 means only what it says, and
notes that 0 becomes the better default once FOLIO passes real status codes through.

Also corrects the Overview and Quick Start, which claimed no retries happen by
default. That was already inaccurate before this change: the authentication flow has
always made one automatic 401/403 retry attempt regardless of configuration, and
only the decorator-based retries are opt-in. The two layers are now described
separately so it is clear which one a given variable configures.

Adds a troubleshooting entry for the most likely surprise -- 403 responses taking
about a second longer than before.
@sonarqubecloud

sonarqubecloud Bot commented Aug 17, 2026

Copy link
Copy Markdown

Quality Gate Passed Quality Gate passed

Issues
0 New issues
10 Accepted issues

Measures
0 Security Hotspots
No data about Coverage
0.0% Duplication on New Code

See analysis details on SonarQube Cloud

@btravisebsco
btravisebsco merged commit 3f5fd24 into master Aug 17, 2026
7 checks passed
@btravisebsco
btravisebsco deleted the fix/auth-flow-transient-recovery branch August 17, 2026 08:09
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