Skip to content

Commit 5c9d0e8

Browse files
committed
Fix: make renewal_manager callback only on renewal
1 parent ff538fe commit 5c9d0e8

3 files changed

Lines changed: 95 additions & 10 deletions

File tree

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
setup(
44
name="certapi",
5-
version="1.1.10",
5+
version="1.1.11",
66
packages=find_packages(where="src"),
77
package_dir={"": "src"},
88
install_requires=[

src/certapi/client/renewal_manager.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,16 +192,15 @@ def start(self):
192192
"""
193193
Start the background renewal worker.
194194
195-
The worker immediately performs a forced renewal pass, then sleeps until
196-
the next watched certificate approaches the renewal threshold or until
197-
another thread publishes a new set with :meth:`update_watch_domains`.
195+
The worker sleeps until the next watched certificate approaches the
196+
renewal threshold or until another thread publishes a new set with
197+
:meth:`update_watch_domains`.
198198
Calling ``start`` while already running is a no-op.
199199
"""
200200
with self._lock:
201201
if self._running:
202202
return
203203
self._running = True
204-
self._cycle_requested = True
205204
self._thread = threading.Thread(target=self._worker, name="CertApi-RenewalManager", daemon=True)
206205
self._thread.start()
207206

@@ -281,8 +280,18 @@ def _worker(self):
281280
if not self._running:
282281
return
283282
force = self._force_trigger
284-
self._cycle_requested = False
285283
self._force_trigger = False
284+
cycle_requested = self._cycle_requested
285+
self._cycle_requested = False
286+
287+
if not force and not cycle_requested:
288+
wait_seconds = self._compute_wait_seconds_locked(self.clock_fn())
289+
if wait_seconds is None:
290+
self._lock.wait()
291+
continue
292+
if wait_seconds > 0:
293+
self._lock.wait(wait_seconds)
294+
continue
286295

287296
attempt_count = self._run_cycle(force=force)
288297

@@ -291,7 +300,7 @@ def _worker(self):
291300
return
292301
if self._force_trigger or self._cycle_requested:
293302
continue
294-
wait_seconds = self._compute_wait_seconds(self.clock_fn())
303+
wait_seconds = self._compute_wait_seconds_locked(self.clock_fn())
295304

296305
# Avoid tight loops in cases where nothing was attempted and no wait was computed.
297306
if (wait_seconds is not None and wait_seconds <= 0) and attempt_count == 0:
@@ -322,10 +331,12 @@ def _due_window_secs(self) -> float:
322331

323332
def _compute_wait_seconds(self, now: datetime) -> Optional[float]:
324333
with self._lock:
325-
if not self._cache:
326-
return None
327-
next_ssl_expiry = min(self._cache.values())
334+
return self._compute_wait_seconds_locked(now)
328335

336+
def _compute_wait_seconds_locked(self, now: datetime) -> Optional[float]:
337+
if not self._cache:
338+
return None
339+
next_ssl_expiry = min(self._cache.values())
329340
remaining_seconds = (next_ssl_expiry - now).total_seconds()
330341
if remaining_seconds > self.update_threshold_secs:
331342
return min(

tests/test_renewal_manager.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,79 @@ def renewal_callback():
628628
assert len(client.calls) == 1
629629

630630

631+
def test_start_does_not_invoke_callback_without_due_watched_cert():
632+
now = datetime(2026, 1, 1, tzinfo=UTC)
633+
callback_called = threading.Event()
634+
635+
mgr = RenewalManager(
636+
DummyClient(),
637+
renewal_callback=callback_called.set,
638+
renew_threshold_days=30,
639+
clock_fn=lambda: now,
640+
)
641+
642+
mgr.start()
643+
try:
644+
assert not callback_called.wait(timeout=0.05)
645+
finally:
646+
mgr.stop()
647+
648+
649+
def test_update_watch_domains_does_not_invoke_callback_for_fresh_cert():
650+
now = datetime(2026, 1, 1, tzinfo=UTC)
651+
callback_called = threading.Event()
652+
client = DummyClient()
653+
client.key_store = DummyKeyStore()
654+
key = Key.generate("ecdsa")
655+
csr = key.create_csr(domain="fresh-callback.example.com", alt_names=["fresh-callback.example.com"])
656+
cert_builder = (
657+
x509.CertificateBuilder()
658+
.subject_name(csr.subject)
659+
.issuer_name(x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "pytest.certapi.local")]))
660+
.public_key(csr.public_key())
661+
.serial_number(x509.random_serial_number())
662+
.not_valid_before(now)
663+
.not_valid_after(now + timedelta(days=90))
664+
)
665+
for ext in csr.extensions:
666+
cert_builder = cert_builder.add_extension(ext.value, ext.critical)
667+
cert = key.sign_csr(cert_builder)
668+
client.key_store.set_domain_cert("fresh-callback.example.com", [cert])
669+
670+
mgr = RenewalManager(
671+
client,
672+
renewal_callback=callback_called.set,
673+
renew_threshold_days=30,
674+
clock_fn=lambda: now,
675+
)
676+
mgr.start()
677+
try:
678+
mgr.update_watch_domains(["fresh-callback.example.com"])
679+
assert not callback_called.wait(timeout=0.05)
680+
finally:
681+
mgr.stop()
682+
683+
684+
def test_worker_invokes_callback_when_cached_cert_is_due():
685+
now = datetime(2026, 1, 1, tzinfo=UTC)
686+
callback_called = threading.Event()
687+
mgr = RenewalManager(
688+
DummyClient(),
689+
renewal_callback=callback_called.set,
690+
renew_threshold_days=30,
691+
clock_fn=lambda: now,
692+
)
693+
with mgr._lock:
694+
mgr._watch_domains = {"due-callback.example.com"}
695+
mgr._cache["due-callback.example.com"] = now + timedelta(days=1)
696+
697+
mgr.start()
698+
try:
699+
assert callback_called.wait(timeout=2)
700+
finally:
701+
mgr.stop()
702+
703+
631704
def test_running_trigger_now_blocks_until_callback_update_finishes():
632705
now = datetime(2026, 1, 1, tzinfo=UTC)
633706
obtain_started = threading.Event()
@@ -768,6 +841,7 @@ def test_stop_does_not_wait_for_hung_remote_request_thread():
768841
mgr._watch_domains = {"hung.example.com"}
769842

770843
mgr.start()
844+
mgr.trigger_now()
771845
assert started.wait(timeout=2)
772846
mgr.stop()
773847

0 commit comments

Comments
 (0)