certapi 1.1.4__tar.gz → 1.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {certapi-1.1.4/src/certapi.egg-info → certapi-1.1.5}/PKG-INFO +1 -1
  2. {certapi-1.1.4 → certapi-1.1.5}/setup.py +1 -1
  3. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/client/renewal_manager.py +136 -6
  4. {certapi-1.1.4 → certapi-1.1.5/src/certapi.egg-info}/PKG-INFO +1 -1
  5. {certapi-1.1.4 → certapi-1.1.5}/tests/test_renewal_manager.py +95 -38
  6. {certapi-1.1.4 → certapi-1.1.5}/MANIFEST.in +0 -0
  7. {certapi-1.1.4 → certapi-1.1.5}/README.md +0 -0
  8. {certapi-1.1.4 → certapi-1.1.5}/pyproject.toml +0 -0
  9. {certapi-1.1.4 → certapi-1.1.5}/setup.cfg +0 -0
  10. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/__init__.py +0 -0
  11. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/Acme.py +0 -0
  12. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/AcmeError.py +0 -0
  13. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/Challenge.py +0 -0
  14. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/Order.py +0 -0
  15. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/__init__.py +0 -0
  16. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/acme/http.py +0 -0
  17. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  18. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  19. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  20. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/__init__.py +0 -0
  21. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  22. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  23. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  24. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  25. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  26. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  27. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  28. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/cli.py +0 -0
  29. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/client/__init__.py +0 -0
  30. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/client/cert_manager_client.py +0 -0
  31. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/crypto/__init__.py +0 -0
  32. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/crypto/crypto.py +0 -0
  33. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/crypto/crypto_classes.py +0 -0
  34. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/domain_batching.py +0 -0
  35. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/errors.py +0 -0
  36. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/http/HttpClientBase.py +0 -0
  37. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/http/__init__.py +0 -0
  38. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/http/types.py +0 -0
  39. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  40. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  41. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/__init__.py +0 -0
  42. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/issuers/abstract_certissuer.py +0 -0
  43. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  44. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/KeyStore.py +0 -0
  45. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  46. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  47. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  48. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/keystore/__init__.py +0 -0
  49. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/manager/__init__.py +0 -0
  50. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/manager/acme_cert_manager.py +0 -0
  51. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/__init__.py +0 -0
  52. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/api.py +0 -0
  53. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/cert_api.py +0 -0
  54. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/server/key_api.py +0 -0
  55. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/util.py +0 -0
  56. {certapi-1.1.4 → certapi-1.1.5}/src/certapi/utils.py +0 -0
  57. {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/SOURCES.txt +0 -0
  58. {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/dependency_links.txt +0 -0
  59. {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/entry_points.txt +0 -0
  60. {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/requires.txt +0 -0
  61. {certapi-1.1.4 → certapi-1.1.5}/src/certapi.egg-info/top_level.txt +0 -0
  62. {certapi-1.1.4 → certapi-1.1.5}/tests/test_acme_cert_manager_batching.py +0 -0
  63. {certapi-1.1.4 → certapi-1.1.5}/tests/test_acme_error_handling.py +0 -0
  64. {certapi-1.1.4 → certapi-1.1.5}/tests/test_cert_issuer_generic.py +0 -0
  65. {certapi-1.1.4 → certapi-1.1.5}/tests/test_certs_with_key_types.py +0 -0
  66. {certapi-1.1.4 → certapi-1.1.5}/tests/test_cli.py +0 -0
  67. {certapi-1.1.4 → certapi-1.1.5}/tests/test_domain_batching_boulder_cases.py +0 -0
  68. {certapi-1.1.4 → certapi-1.1.5}/tests/test_http_error_handling.py +0 -0
  69. {certapi-1.1.4 → certapi-1.1.5}/tests/test_keystores.py +0 -0
  70. {certapi-1.1.4 → certapi-1.1.5}/tests/test_obtain_interface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.4
3
+ Version: 1.1.5
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="1.1.4",
5
+ version="1.1.5",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -12,7 +12,15 @@ from certapi.manager.acme_cert_manager import DEFAULT_RENEW_THRESHOLD_DAYS
12
12
 
13
13
  class RenewalManager:
14
14
  """
15
- Background certificate refresh manager.
15
+ Keep certificates fresh for a changing set of watched domains.
16
+
17
+ RenewalManager can run as a background worker with :meth:`start`, while
18
+ callers publish the complete desired domain set through
19
+ :meth:`update_watch_domains`. Publishing a domain set is synchronous: the call
20
+ returns after certapi has processed the set and attempted any due
21
+ obtain/renew work. Each renewal pass first calls the optional
22
+ ``sync_watch_domains`` callback so integrations can republish the latest
23
+ domain list before certapi decides what to obtain or renew.
16
24
 
17
25
  Algorithm:
18
26
  - Maintain a watched domain set and cache each domain's certificate expiry.
@@ -27,6 +35,14 @@ class RenewalManager:
27
35
  - On failure for renewal, where a cached or local certificate exists, keep reusing
28
36
  that existing certificate even if it is expired, defer the next retry, and do not
29
37
  replace it with a self-signed certificate.
38
+
39
+ The manager keeps an in-memory expiry cache, bootstraps that cache from the
40
+ backend keystore when possible, renews missing or soon-expiring
41
+ certificates, and records failures in :meth:`get_state`. New domains that
42
+ fail issuance receive a short-lived retry blacklist and, when the backend
43
+ exposes a keystore, a local self-signed fallback certificate. Renewal
44
+ failures for domains with an existing certificate keep using that
45
+ certificate and schedule a later retry.
30
46
  """
31
47
 
32
48
  def __init__(
@@ -52,6 +68,50 @@ class RenewalManager:
52
68
  organization: Optional[str] = None,
53
69
  user_id: Optional[str] = None,
54
70
  ):
71
+ """
72
+ Create a renewal manager for a certificate backend.
73
+
74
+ Args:
75
+ cert_manager_client: Backend object with an ``obtain(domains,
76
+ **kwargs)`` method. Local managers and ``CertManagerClient`` are
77
+ both supported.
78
+ sync_watch_domains: Optional callback invoked at the start of each
79
+ renewal pass. The callback should call :meth:`update_watch_domains`
80
+ with the latest desired domain list. Calls made from this
81
+ callback only update the in-progress cycle and do not start a
82
+ nested renewal pass.
83
+ renew_threshold_days: Renew certificates when they expire within
84
+ this many days. Defaults to ``CERT_RENEW_THRESHOLD_DAYS`` or the
85
+ certapi default.
86
+ min_renew_threshold_days: Lower bound for renewal attempts, used to
87
+ avoid renewing too close to expiry even when a smaller threshold
88
+ is configured.
89
+ sleep_slack_seconds: Extra delay added after the calculated next
90
+ renewal time to avoid waking exactly on the threshold boundary.
91
+ max_sleep_seconds: Maximum time the background worker sleeps between
92
+ checks.
93
+ renew_retry_interval_seconds: Delay before retrying renewal for a
94
+ domain that already has a local certificate.
95
+ blacklist_duration_seconds: Delay before retrying issuance for a
96
+ domain that had no usable local certificate.
97
+ remote_poll_interval_seconds: Poll interval while waiting for remote
98
+ ``CertManagerClient`` requests.
99
+ clock_fn: Optional clock override for deterministic tests.
100
+ sleep_fn: Optional sleep override for deterministic tests.
101
+ key_type: Private key type requested from the backend.
102
+ expiry_days: Requested certificate lifetime when the backend
103
+ supports it.
104
+ batch_domains: Forwarded to the backend to enable domain batching.
105
+ self_verify: Forwarded to the backend to enable or disable
106
+ ownership self-verification.
107
+ country: Optional subject country for issued or fallback certs.
108
+ state: Optional subject state for issued or fallback certs.
109
+ locality: Optional subject locality for issued or fallback certs.
110
+ organization: Optional subject organization for issued or fallback
111
+ certs.
112
+ user_id: Optional subject/user identifier for issued or fallback
113
+ certs.
114
+ """
55
115
  self.cert_manager_client = cert_manager_client
56
116
  self.sync_watch_domains = sync_watch_domains
57
117
  self.key_type = key_type
@@ -89,34 +149,73 @@ class RenewalManager:
89
149
  self._lock = threading.Condition()
90
150
  self._thread: Optional[threading.Thread] = None
91
151
  self._running = False
152
+ self._cycle_requested = False
92
153
  self._force_trigger = False
93
154
  self._cycle_running = False
94
155
  self._cycle_thread_id: Optional[int] = None
156
+ self._cycle_generation = 0
95
157
  self._blacklist: Dict[str, datetime] = {}
96
158
  self._last_error_message: Optional[str] = None
97
159
  self._last_error_timestamp: Optional[datetime] = None
98
160
  self._self_signer: Optional[SelfCertIssuer] = None
99
161
 
100
- def set_watch_domains(self, domains: List[str]):
162
+ def update_watch_domains(self, domains: List[str]):
163
+ """
164
+ Replace the complete set of domains and process it before returning.
165
+
166
+ Empty values are ignored. Cache and blacklist entries for domains no
167
+ longer being watched are dropped.
168
+
169
+ When called outside an active renewal cycle, this method immediately
170
+ runs or wakes a renewal pass and blocks until certapi has attempted any
171
+ due obtain/renew work for the published set. When called from
172
+ ``sync_watch_domains`` during a renewal cycle, it only updates the set
173
+ being processed and returns immediately to avoid recursive cycles.
174
+ """
101
175
  new_watch_set = {x for x in domains if x}
102
176
  with self._lock:
103
177
  self._watch_domains = new_watch_set
104
178
  self._cache = {d: expiry for d, expiry in self._cache.items() if d in self._watch_domains}
105
179
  self._blacklist = {d: exp for d, exp in self._blacklist.items() if d in self._watch_domains}
106
- if self._running and self._cycle_thread_id != threading.get_ident():
107
- self._force_trigger = True
180
+ if self._cycle_thread_id == threading.get_ident():
181
+ self._lock.notify_all()
182
+ return
183
+ if self._running:
184
+ target_generation = self._cycle_generation + (2 if self._cycle_running else 1)
185
+ self._cycle_requested = True
186
+ self._lock.notify_all()
187
+ while self._running and self._cycle_generation < target_generation:
188
+ self._lock.wait()
189
+ return
108
190
  self._lock.notify_all()
109
191
 
192
+ self._run_cycle(force=False)
193
+
110
194
  def start(self):
195
+ """
196
+ Start the background renewal worker.
197
+
198
+ The worker immediately performs a forced renewal pass, then sleeps until
199
+ the next watched certificate approaches the renewal threshold or until
200
+ another thread publishes a new set with :meth:`update_watch_domains`.
201
+ Calling ``start`` while already running is a no-op.
202
+ """
111
203
  with self._lock:
112
204
  if self._running:
113
205
  return
114
206
  self._running = True
115
- self._force_trigger = True
207
+ self._cycle_requested = True
116
208
  self._thread = threading.Thread(target=self._worker, name="CertApi-RenewalManager", daemon=True)
117
209
  self._thread.start()
118
210
 
119
211
  def stop(self):
212
+ """
213
+ Stop the background renewal worker and wait briefly for it to exit.
214
+
215
+ In-flight remote certificate requests run in daemon helper threads and
216
+ are not waited on indefinitely; the manager stops scheduling new work
217
+ and joins the worker thread for up to two seconds.
218
+ """
120
219
  thread = None
121
220
  with self._lock:
122
221
  self._running = False
@@ -127,14 +226,43 @@ class RenewalManager:
127
226
  thread.join(timeout=2)
128
227
 
129
228
  def trigger_now(self):
229
+ """
230
+ Run a renewal pass immediately.
231
+
232
+ When the background worker is not running, this method performs the
233
+ renewal pass in the caller's thread and returns after it completes. When
234
+ the worker is running, this method wakes it and blocks until the
235
+ requested renewal pass has completed.
236
+
237
+ Most integrations should call :meth:`update_watch_domains` instead. This
238
+ method is for forcing a pass without changing the watched domain set.
239
+
240
+ If called from inside the renewal cycle itself, the method only requests
241
+ a follow-up pass and returns immediately to avoid deadlock.
242
+ """
130
243
  with self._lock:
131
244
  if self._running:
245
+ if self._cycle_thread_id == threading.get_ident():
246
+ self._force_trigger = True
247
+ self._lock.notify_all()
248
+ return
249
+ target_generation = self._cycle_generation + (2 if self._cycle_running else 1)
132
250
  self._force_trigger = True
133
251
  self._lock.notify_all()
252
+ while self._running and self._cycle_generation < target_generation:
253
+ self._lock.wait()
134
254
  return
135
255
  self._run_cycle(force=True)
136
256
 
137
257
  def get_state(self) -> Dict[str, Any]:
258
+ """
259
+ Return a snapshot of renewal-manager state for diagnostics.
260
+
261
+ The returned dictionary includes watched domains, cache size, active
262
+ blacklisted domains, next cached expiry time, whether the background
263
+ worker is running, and the most recent error message/timestamp recorded
264
+ by the manager.
265
+ """
138
266
  with self._lock:
139
267
  next_renewal_time = min(self._cache.values()).isoformat() if self._cache else None
140
268
  return {
@@ -157,6 +285,7 @@ class RenewalManager:
157
285
  if not self._running:
158
286
  return
159
287
  force = self._force_trigger
288
+ self._cycle_requested = False
160
289
  self._force_trigger = False
161
290
 
162
291
  attempt_count = self._run_cycle(force=force)
@@ -164,7 +293,7 @@ class RenewalManager:
164
293
  with self._lock:
165
294
  if not self._running:
166
295
  return
167
- if self._force_trigger:
296
+ if self._force_trigger or self._cycle_requested:
168
297
  continue
169
298
  wait_seconds = self._compute_wait_seconds(self.clock_fn())
170
299
 
@@ -233,6 +362,7 @@ class RenewalManager:
233
362
  with self._lock:
234
363
  self._cycle_running = False
235
364
  self._cycle_thread_id = None
365
+ self._cycle_generation += 1
236
366
  self._lock.notify_all()
237
367
 
238
368
  def _run_cycle_body(self, force: bool = False) -> int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.4
3
+ Version: 1.1.5
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -158,7 +158,7 @@ def test_default_threshold_and_min_floor(monkeypatch):
158
158
  def test_sleep_computation_slack_and_cap():
159
159
  now = datetime(2026, 1, 1, tzinfo=UTC)
160
160
  mgr = RenewalManager(DummyClient(), renew_threshold_days=30)
161
- mgr.set_watch_domains(["example.com"])
161
+ mgr.update_watch_domains(["example.com"])
162
162
 
163
163
  with mgr._lock:
164
164
  mgr._cache["example.com"] = now + timedelta(days=60)
@@ -187,8 +187,8 @@ def test_existing_cert_failure_sets_threshold_plus_24h_retry():
187
187
  renew_threshold_days=30,
188
188
  clock_fn=lambda: clock["now"],
189
189
  )
190
- mgr.set_watch_domains(["example.com"])
191
190
  with mgr._lock:
191
+ mgr._watch_domains = {"example.com"}
192
192
  mgr._cache["example.com"] = now - timedelta(hours=1)
193
193
 
194
194
  mgr.trigger_now()
@@ -222,7 +222,7 @@ def test_existing_cert_failure_does_not_selfsign():
222
222
  client.set_handler("existing.example.com", RuntimeError("renew failed"))
223
223
 
224
224
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
225
- mgr.set_watch_domains(["existing.example.com"])
225
+ mgr.update_watch_domains(["existing.example.com"])
226
226
  with mgr._lock:
227
227
  mgr._cache["existing.example.com"] = now - timedelta(hours=1)
228
228
 
@@ -255,9 +255,7 @@ def test_expired_local_cert_failure_keeps_existing_cert_and_defers_retry():
255
255
  client.set_handler("expired.example.com", RuntimeError("renew failed"))
256
256
 
257
257
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
258
- mgr.set_watch_domains(["expired.example.com"])
259
-
260
- mgr.trigger_now()
258
+ mgr.update_watch_domains(["expired.example.com"])
261
259
 
262
260
  expected = now + timedelta(seconds=mgr.update_threshold_secs + mgr.renew_retry_interval_seconds)
263
261
  with mgr._lock:
@@ -293,8 +291,7 @@ def test_expired_sqlite_cert_failure_does_not_seed_selfsigned_as_fresh():
293
291
  client.set_handler("sqlite-expired.example.com", RuntimeError("renew failed"))
294
292
 
295
293
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
296
- mgr.set_watch_domains(["sqlite-expired.example.com"])
297
- mgr.trigger_now()
294
+ mgr.update_watch_domains(["sqlite-expired.example.com"])
298
295
 
299
296
  with mgr._lock:
300
297
  assert "sqlite-expired.example.com" in mgr._cache
@@ -312,7 +309,7 @@ def test_retry_deferral_suppresses_immediate_retry_and_force_when_blacklisted():
312
309
  client.set_handler("retry.example.com", RuntimeError("renew failed"))
313
310
 
314
311
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: clock["now"])
315
- mgr.set_watch_domains(["retry.example.com"])
312
+ mgr.update_watch_domains(["retry.example.com"])
316
313
  with mgr._lock:
317
314
  mgr._cache["retry.example.com"] = now - timedelta(minutes=1)
318
315
 
@@ -340,15 +337,15 @@ def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replac
340
337
  mgr = None
341
338
 
342
339
  def sync_watch_domains():
343
- mgr.set_watch_domains(["c.example.com"])
340
+ mgr.update_watch_domains(["c.example.com"])
344
341
 
345
342
  mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
346
- mgr.set_watch_domains(["a.example.com", "b.example.com"])
343
+ mgr.update_watch_domains(["a.example.com", "b.example.com"])
347
344
  with mgr._lock:
348
345
  mgr._cache["a.example.com"] = now + timedelta(days=20)
349
346
  mgr._cache["b.example.com"] = now + timedelta(days=20)
350
347
 
351
- mgr.set_watch_domains(["a.example.com"])
348
+ mgr.update_watch_domains(["a.example.com"])
352
349
  with mgr._lock:
353
350
  assert "b.example.com" not in mgr._cache
354
351
 
@@ -378,7 +375,7 @@ def test_bootstrap_and_cache_update_from_issued_and_existing():
378
375
  )
379
376
 
380
377
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
381
- mgr.set_watch_domains(["issued.example.com", "existing.example.com"])
378
+ mgr.update_watch_domains(["issued.example.com", "existing.example.com"])
382
379
 
383
380
  # No cache initially; both should be obtained immediately to bootstrap.
384
381
  mgr._run_cycle(force=False)
@@ -404,8 +401,7 @@ def test_renewal_manager_prefers_obtain_when_available():
404
401
  )
405
402
 
406
403
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
407
- mgr.set_watch_domains(["prefer-obtain.example.com"])
408
- mgr._run_cycle(force=False)
404
+ mgr.update_watch_domains(["prefer-obtain.example.com"])
409
405
 
410
406
  assert len(client.obtain_calls) == 1
411
407
  assert len(client.issue_calls) == 0
@@ -428,8 +424,7 @@ def test_renewal_manager_passes_typed_obtain_options_into_renewal_calls():
428
424
  self_verify=False,
429
425
  organization="certapi-tests",
430
426
  )
431
- mgr.set_watch_domains(["batch.example.com"])
432
- mgr.trigger_now()
427
+ mgr.update_watch_domains(["batch.example.com"])
433
428
 
434
429
  assert len(client.calls) == 1
435
430
  assert client.calls[0]["kwargs"]["batch_domains"] is True
@@ -461,8 +456,7 @@ def test_local_keystore_seed_prevents_remote_call_when_fresh():
461
456
  client.key_store.set_domain_cert("fresh.example.com", [cert])
462
457
 
463
458
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
464
- mgr.set_watch_domains(["fresh.example.com"])
465
- mgr._run_cycle(force=False)
459
+ mgr.update_watch_domains(["fresh.example.com"])
466
460
 
467
461
  with mgr._lock:
468
462
  assert "fresh.example.com" in mgr._cache
@@ -496,8 +490,7 @@ def test_local_keystore_seed_stale_cert_still_renews():
496
490
  )
497
491
 
498
492
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
499
- mgr.set_watch_domains(["stale.example.com"])
500
- mgr._run_cycle(force=False)
493
+ mgr.update_watch_domains(["stale.example.com"])
501
494
 
502
495
  assert len(client.calls) == 1
503
496
  with mgr._lock:
@@ -517,9 +510,8 @@ def test_new_domain_failure_selfsigns_and_blacklists():
517
510
  blacklist_duration_seconds=180,
518
511
  clock_fn=lambda: clock["now"],
519
512
  )
520
- mgr.set_watch_domains(["new.example.com"])
513
+ mgr.update_watch_domains(["new.example.com"])
521
514
 
522
- mgr._run_cycle(force=False)
523
515
  assert len(client.calls) == 1
524
516
  assert len(client.key_store.saved_certs) == 1
525
517
  assert client.key_store.saved_certs[0][2] == "new.example.com.selfsigned"
@@ -550,19 +542,37 @@ def test_sync_callback_exception_sets_state_error():
550
542
  assert state["last_error_timestamp"] == now.isoformat()
551
543
 
552
544
 
553
- def test_set_watch_domains_external_update_requests_followup_cycle_when_running():
545
+ def test_update_watch_domains_blocks_until_running_worker_processes_domains():
546
+ now = datetime(2026, 1, 1, tzinfo=UTC)
547
+ started = threading.Event()
548
+ release = threading.Event()
554
549
  client = DummyClient()
555
- mgr = RenewalManager(client)
556
- with mgr._lock:
557
- mgr._running = True
550
+ cert = _make_cert_pem("requested.example.com", now, valid_for_days=90)
558
551
 
559
- mgr.set_watch_domains(["requested.example.com"])
552
+ def slow_handler(host, kwargs):
553
+ started.set()
554
+ release.wait(timeout=2)
555
+ return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
560
556
 
561
- with mgr._lock:
562
- assert mgr._force_trigger is True
557
+ client.set_handler("requested.example.com", slow_handler)
558
+ mgr = RenewalManager(client, clock_fn=lambda: now)
559
+ mgr.start()
560
+
561
+ update_thread = threading.Thread(target=lambda: mgr.update_watch_domains(["requested.example.com"]))
562
+ update_thread.start()
563
+
564
+ assert started.wait(timeout=2)
565
+ assert update_thread.is_alive()
566
+ release.set()
567
+ update_thread.join(timeout=2)
568
+ mgr.stop()
569
+
570
+ assert not update_thread.is_alive()
571
+ assert len(client.calls) == 1
572
+ assert client.calls[0]["host"] == "requested.example.com"
563
573
 
564
574
 
565
- def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
575
+ def test_update_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
566
576
  now = datetime(2026, 1, 1, tzinfo=UTC)
567
577
  client = DummyClient()
568
578
  cert = _make_cert_pem("from-callback.example.com", now, valid_for_days=60)
@@ -573,7 +583,7 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
573
583
  mgr = None
574
584
 
575
585
  def sync_watch_domains():
576
- mgr.set_watch_domains(["from-callback.example.com"])
586
+ mgr.update_watch_domains(["from-callback.example.com"])
577
587
 
578
588
  mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
579
589
  with mgr._lock:
@@ -583,9 +593,55 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
583
593
 
584
594
  with mgr._lock:
585
595
  assert mgr._force_trigger is False
596
+ assert mgr._cycle_requested is False
586
597
  assert len(client.calls) == 1
587
598
 
588
599
 
600
+ def test_running_trigger_now_blocks_until_synced_cycle_finishes():
601
+ now = datetime(2026, 1, 1, tzinfo=UTC)
602
+ sync_count = 0
603
+ sync_ready = threading.Event()
604
+ sync_enabled = threading.Event()
605
+ obtain_started = threading.Event()
606
+ release_obtain = threading.Event()
607
+ client = DummyClient()
608
+ cert = _make_cert_pem("blocking.example.com", now, valid_for_days=90)
609
+ mgr = None
610
+
611
+ def slow_handler(host, kwargs):
612
+ obtain_started.set()
613
+ release_obtain.wait(timeout=2)
614
+ return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
615
+
616
+ def sync_watch_domains():
617
+ nonlocal sync_count
618
+ sync_count += 1
619
+ sync_ready.set()
620
+ if sync_enabled.is_set():
621
+ mgr.update_watch_domains(["blocking.example.com"])
622
+
623
+ client.set_handler("blocking.example.com", slow_handler)
624
+ mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
625
+
626
+ mgr.start()
627
+ assert sync_ready.wait(timeout=2)
628
+
629
+ sync_enabled.set()
630
+ trigger_thread = threading.Thread(target=mgr.trigger_now)
631
+ trigger_thread.start()
632
+
633
+ assert obtain_started.wait(timeout=2)
634
+ assert trigger_thread.is_alive()
635
+ release_obtain.set()
636
+ trigger_thread.join(timeout=2)
637
+
638
+ mgr.stop()
639
+ assert not trigger_thread.is_alive()
640
+ assert sync_count >= 2
641
+ assert len(client.calls) == 1
642
+ assert client.calls[0]["host"] == "blocking.example.com"
643
+
644
+
589
645
  def test_singleflight_suppresses_concurrent_manual_cycles():
590
646
  now = datetime(2026, 1, 1, tzinfo=UTC)
591
647
  started = threading.Event()
@@ -600,7 +656,8 @@ def test_singleflight_suppresses_concurrent_manual_cycles():
600
656
 
601
657
  client.set_handler("singleflight.example.com", slow_handler)
602
658
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
603
- mgr.set_watch_domains(["singleflight.example.com"])
659
+ with mgr._lock:
660
+ mgr._watch_domains = {"singleflight.example.com"}
604
661
 
605
662
  first = threading.Thread(target=lambda: mgr.trigger_now())
606
663
  first.start()
@@ -630,7 +687,8 @@ def test_remote_certapi_polling_prints_waiting_message(capsys):
630
687
  remote_poll_interval_seconds=0.01,
631
688
  clock_fn=lambda: now,
632
689
  )
633
- mgr.set_watch_domains(["remote.example.com"])
690
+ with mgr._lock:
691
+ mgr._watch_domains = {"remote.example.com"}
634
692
 
635
693
  thread = threading.Thread(target=lambda: mgr.trigger_now())
636
694
  thread.start()
@@ -651,9 +709,7 @@ def test_remote_renewal_disables_skip_failing_so_unverified_domain_fails():
651
709
  client.key_store = DummyKeyStore()
652
710
 
653
711
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
654
- mgr.set_watch_domains(["missing.example.com"])
655
-
656
- mgr.trigger_now()
712
+ mgr.update_watch_domains(["missing.example.com"])
657
713
 
658
714
  assert len(client.obtain_calls) == 1
659
715
  assert client.obtain_calls[0]["kwargs"]["skip_failing"] is False
@@ -668,7 +724,8 @@ def test_stop_does_not_wait_for_hung_remote_request_thread():
668
724
  release = threading.Event()
669
725
  client = DummyRemoteClient(started=started, release=release)
670
726
  mgr = RenewalManager(client, remote_poll_interval_seconds=0.01)
671
- mgr.set_watch_domains(["hung.example.com"])
727
+ with mgr._lock:
728
+ mgr._watch_domains = {"hung.example.com"}
672
729
 
673
730
  mgr.start()
674
731
  assert started.wait(timeout=2)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes