From ff2953e9e68b8db959701341af179eecc6fec116 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Thu, 25 Jun 2026 07:56:45 +0200 Subject: [PATCH] Read _expire_at only once in has_expired() Backport of https://github.com/encode/httpcore/pull/1090. In has_expired() the _expire_at attribute was read twice: once for the `is not None` guard and once for the comparison. On free-threaded builds another thread may reset it to None between the two reads, raising a TypeError. Read it once into a local instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/httpcore2/httpcore2/_async/http11.py | 5 ++++- src/httpcore2/httpcore2/_async/http2.py | 5 ++++- src/httpcore2/httpcore2/_sync/http11.py | 5 ++++- src/httpcore2/httpcore2/_sync/http2.py | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/httpcore2/httpcore2/_async/http11.py b/src/httpcore2/httpcore2/_async/http11.py index 769825d9..1248bbff 100644 --- a/src/httpcore2/httpcore2/_async/http11.py +++ b/src/httpcore2/httpcore2/_async/http11.py @@ -251,7 +251,10 @@ def is_available(self) -> bool: def has_expired(self) -> bool: now = time.monotonic() - keepalive_expired = self._expire_at is not None and now > self._expire_at + # Read `_expire_at` once into a local: on free-threaded builds another + # thread may reset it to `None` between the check and the comparison. + expire_at = self._expire_at + keepalive_expired = expire_at is not None and now > expire_at # If the HTTP connection is idle but the socket is readable, then the # only valid state is that the socket is about to return b"", indicating diff --git a/src/httpcore2/httpcore2/_async/http2.py b/src/httpcore2/httpcore2/_async/http2.py index 2c143e07..af5b911a 100644 --- a/src/httpcore2/httpcore2/_async/http2.py +++ b/src/httpcore2/httpcore2/_async/http2.py @@ -484,7 +484,10 @@ def is_available(self) -> bool: def has_expired(self) -> bool: now = time.monotonic() - return self._expire_at is not None and now > self._expire_at + # Read `_expire_at` once into a local: on free-threaded builds another + # thread may reset it to `None` between the check and the comparison. + expire_at = self._expire_at + return expire_at is not None and now > expire_at def is_idle(self) -> bool: return self._state == HTTPConnectionState.IDLE diff --git a/src/httpcore2/httpcore2/_sync/http11.py b/src/httpcore2/httpcore2/_sync/http11.py index 14d674ae..3d6b9d19 100644 --- a/src/httpcore2/httpcore2/_sync/http11.py +++ b/src/httpcore2/httpcore2/_sync/http11.py @@ -251,7 +251,10 @@ def is_available(self) -> bool: def has_expired(self) -> bool: now = time.monotonic() - keepalive_expired = self._expire_at is not None and now > self._expire_at + # Read `_expire_at` once into a local: on free-threaded builds another + # thread may reset it to `None` between the check and the comparison. + expire_at = self._expire_at + keepalive_expired = expire_at is not None and now > expire_at # If the HTTP connection is idle but the socket is readable, then the # only valid state is that the socket is about to return b"", indicating diff --git a/src/httpcore2/httpcore2/_sync/http2.py b/src/httpcore2/httpcore2/_sync/http2.py index 6daf0ac8..457d2a8b 100644 --- a/src/httpcore2/httpcore2/_sync/http2.py +++ b/src/httpcore2/httpcore2/_sync/http2.py @@ -484,7 +484,10 @@ def is_available(self) -> bool: def has_expired(self) -> bool: now = time.monotonic() - return self._expire_at is not None and now > self._expire_at + # Read `_expire_at` once into a local: on free-threaded builds another + # thread may reset it to `None` between the check and the comparison. + expire_at = self._expire_at + return expire_at is not None and now > expire_at def is_idle(self) -> bool: return self._state == HTTPConnectionState.IDLE