devnomads 0.2.5__tar.gz → 0.3.0__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 (40) hide show
  1. {devnomads-0.2.5 → devnomads-0.3.0}/PKG-INFO +2 -1
  2. {devnomads-0.2.5 → devnomads-0.3.0}/pyproject.toml +8 -2
  3. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/__init__.py +2 -1
  4. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/client.py +63 -20
  5. devnomads-0.3.0/src/devnomads/acme/errors.py +27 -0
  6. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_acme_client.py +131 -1
  7. {devnomads-0.2.5 → devnomads-0.3.0}/uv.lock +3 -1
  8. devnomads-0.2.5/src/devnomads/acme/errors.py +0 -9
  9. {devnomads-0.2.5 → devnomads-0.3.0}/.flake8 +0 -0
  10. {devnomads-0.2.5 → devnomads-0.3.0}/.gitignore +0 -0
  11. {devnomads-0.2.5 → devnomads-0.3.0}/.gitlab-ci.yml +0 -0
  12. {devnomads-0.2.5 → devnomads-0.3.0}/Makefile +0 -0
  13. {devnomads-0.2.5 → devnomads-0.3.0}/README.md +0 -0
  14. {devnomads-0.2.5 → devnomads-0.3.0}/openapi.json +0 -0
  15. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/__init__.py +0 -0
  16. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/challenge_server.py +0 -0
  17. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/dns01.py +0 -0
  18. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/http01.py +0 -0
  19. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/keys.py +0 -0
  20. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/verify.py +0 -0
  21. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/__init__.py +0 -0
  22. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/_services.py +0 -0
  23. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/client.py +0 -0
  24. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/credentials.py +0 -0
  25. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/errors.py +0 -0
  26. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/__init__.py +0 -0
  27. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/errors.py +0 -0
  28. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/names.py +0 -0
  29. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/zones.py +0 -0
  30. {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/py.typed +0 -0
  31. {devnomads-0.2.5 → devnomads-0.3.0}/tests/conftest.py +0 -0
  32. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_acme_challenges.py +0 -0
  33. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_acme_keys.py +0 -0
  34. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_client.py +0 -0
  35. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_credentials.py +0 -0
  36. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_dns.py +0 -0
  37. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_names.py +0 -0
  38. {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_services.py +0 -0
  39. {devnomads-0.2.5 → devnomads-0.3.0}/tools/bump_version.py +0 -0
  40. {devnomads-0.2.5 → devnomads-0.3.0}/tools/generate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads
3
- Version: 0.2.5
3
+ Version: 0.3.0
4
4
  Summary: Python client library for the DevNomads API (transport, DNS, ACME)
5
5
  Project-URL: Homepage, https://devnomads.nl
6
6
  Author-email: DevNomads <support@devnomads.nl>
@@ -13,6 +13,7 @@ Requires-Dist: acme>=2.11; extra == 'acme'
13
13
  Requires-Dist: cryptography>=42; extra == 'acme'
14
14
  Requires-Dist: dnspython>=2.6; extra == 'acme'
15
15
  Requires-Dist: josepy>=1.14; extra == 'acme'
16
+ Requires-Dist: requests>=2.31; extra == 'acme'
16
17
  Description-Content-Type: text/markdown
17
18
 
18
19
  # devnomads
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devnomads"
7
- version = "0.2.5"
7
+ version = "0.3.0"
8
8
  description = "Python client library for the DevNomads API (transport, DNS, ACME)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -17,7 +17,13 @@ dependencies = ["httpx>=0.27"]
17
17
  # The ACME client pulls in the only heavy dependencies in the project, so
18
18
  # it lives behind an extra: `pip install devnomads[acme]`. Without it,
19
19
  # `import devnomads.acme` raises a clear, actionable ImportError.
20
- acme = ["acme>=2.11", "josepy>=1.14", "cryptography>=42", "dnspython>=2.6"]
20
+ acme = [
21
+ "acme>=2.11",
22
+ "josepy>=1.14",
23
+ "cryptography>=42",
24
+ "dnspython>=2.6",
25
+ "requests>=2.31",
26
+ ]
21
27
 
22
28
  [project.urls]
23
29
  Homepage = "https://devnomads.nl"
@@ -26,7 +26,7 @@ try:
26
26
  AcmeClient,
27
27
  )
28
28
  from .dns01 import DevNomadsDnsProvider, DnsProvider
29
- from .errors import AcmeError
29
+ from .errors import AcmeError, TransientAcmeError
30
30
  from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
31
31
  from .keys import (
32
32
  build_csr,
@@ -52,6 +52,7 @@ __all__ = [
52
52
  "ZEROSSL_DIRECTORY_URL",
53
53
  "CA_DIRECTORIES",
54
54
  "AcmeError",
55
+ "TransientAcmeError",
55
56
  "DnsProvider",
56
57
  "DevNomadsDnsProvider",
57
58
  "Http01Solver",
@@ -3,16 +3,18 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from contextlib import contextmanager
6
7
  from datetime import datetime, timedelta
7
8
  from time import sleep, time
8
9
 
10
+ import requests
9
11
  from acme import challenges
10
12
  from acme import client as acme_client
11
13
  from acme import errors as acme_errors
12
14
  from acme import messages
13
15
 
14
16
  from .dns01 import DnsProvider
15
- from .errors import AcmeError
17
+ from .errors import AcmeError, TransientAcmeError
16
18
  from .http01 import Http01Solver
17
19
  from .keys import (
18
20
  PrivateKey,
@@ -45,6 +47,35 @@ CA_DIRECTORIES = {
45
47
  # account registration.
46
48
  _EAB_REQUIRED_PROVIDERS = frozenset({"zerossl"})
47
49
 
50
+ # ACME problem-document codes that mean "the CA had a problem", not "the
51
+ # subject is broken". These are retry-safe: back off and try again later.
52
+ _TRANSIENT_ACME_CODES = {"serverInternal", "rateLimited", "badNonce"}
53
+
54
+
55
+ @contextmanager
56
+ def _reraise_transient(step: str):
57
+ """Re-raise infrastructure-level failures as :class:`TransientAcmeError`.
58
+
59
+ Wraps a network-facing ACME step. Structural ACME errors (validation
60
+ failures, unauthorized, CAA, ...) pass through untouched so callers can
61
+ still treat them as permanent.
62
+ """
63
+
64
+ try:
65
+ yield
66
+ except requests.exceptions.RequestException as exc:
67
+ raise TransientAcmeError(f"{step}: CA unreachable: {exc}") from exc
68
+ except messages.Error as exc:
69
+ if exc.code in _TRANSIENT_ACME_CODES:
70
+ raise TransientAcmeError(f"{step}: {exc}", code=exc.code) from exc
71
+ raise
72
+ except (
73
+ acme_errors.TimeoutError,
74
+ acme_errors.PollError,
75
+ acme_errors.NonceError,
76
+ ) as exc:
77
+ raise TransientAcmeError(f"{step}: {exc}") from exc
78
+
48
79
 
49
80
  def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str | None:
50
81
  """Resolve the configured challenge type for one authorization.
@@ -139,7 +170,8 @@ class AcmeClient:
139
170
  net = acme_client.ClientNetwork(
140
171
  account_key, alg=jws_alg(account_key), user_agent=self.user_agent
141
172
  )
142
- directory = messages.Directory.from_json(net.get(self.directory_url).json())
173
+ with _reraise_transient("directory fetch"):
174
+ directory = messages.Directory.from_json(net.get(self.directory_url).json())
143
175
  client = acme_client.ClientV2(directory, net)
144
176
 
145
177
  registration_kwargs = dict(
@@ -161,14 +193,15 @@ class AcmeClient:
161
193
  new_account = messages.NewRegistration(**registration_kwargs)
162
194
 
163
195
  account = None
164
- try:
165
- account = client.new_account(new_account)
166
- except acme_errors.ConflictError as exc:
167
- uri = getattr(exc, "location", None) or getattr(
168
- getattr(exc, "response", None), "headers", {}
169
- ).get("Location")
170
- if uri:
171
- account = messages.RegistrationResource(body=None, uri=uri)
196
+ with _reraise_transient("account registration"):
197
+ try:
198
+ account = client.new_account(new_account)
199
+ except acme_errors.ConflictError as exc:
200
+ uri = getattr(exc, "location", None) or getattr(
201
+ getattr(exc, "response", None), "headers", {}
202
+ ).get("Location")
203
+ if uri:
204
+ account = messages.RegistrationResource(body=None, uri=uri)
172
205
 
173
206
  if account is None:
174
207
  account = getattr(client.net, "account", None)
@@ -183,13 +216,14 @@ class AcmeClient:
183
216
  def _wait_for_authz(self, client, authz, timeout: int = 300):
184
217
  deadline = time() + timeout
185
218
  while time() < deadline:
186
- authz, _ = client.poll(authz)
219
+ with _reraise_transient("authorization poll"):
220
+ authz, _ = client.poll(authz)
187
221
  if authz.body.status == messages.STATUS_VALID:
188
222
  return authz
189
223
  if authz.body.status == messages.STATUS_INVALID:
190
224
  raise AcmeError(_authz_failure(authz.body))
191
225
  sleep(2)
192
- raise AcmeError("authorization polling timed out")
226
+ raise TransientAcmeError("authorization polling timed out")
193
227
 
194
228
  def _fetch_order_authorizations(self, client, order):
195
229
  raw = getattr(order, "authorizations", None) or []
@@ -235,7 +269,8 @@ class AcmeClient:
235
269
  csr_pem = build_csr(domain_key, identifiers)
236
270
  log.info("requesting certificate for: %s", ", ".join(identifiers))
237
271
 
238
- order = client.new_order(csr_pem)
272
+ with _reraise_transient("new order"):
273
+ order = client.new_order(csr_pem)
239
274
  authzs = self._fetch_order_authorizations(client, order)
240
275
 
241
276
  http_tokens: list[str] = []
@@ -258,9 +293,10 @@ class AcmeClient:
258
293
  client, dns_challenges, dns_propagation_delay
259
294
  )
260
295
 
261
- order = client.poll_and_finalize(
262
- order, deadline=datetime.now() + timedelta(seconds=300)
263
- )
296
+ with _reraise_transient("finalize order"):
297
+ order = client.poll_and_finalize(
298
+ order, deadline=datetime.now() + timedelta(seconds=300)
299
+ )
264
300
  finally:
265
301
  self._cleanup(http01_solver, http_tokens, dns_provider, dns_challenges)
266
302
 
@@ -308,7 +344,12 @@ class AcmeClient:
308
344
  if dns_challenges:
309
345
  sleep(1) # space out API calls to avoid rate limiting
310
346
  log.info("planting dns-01 challenge for %s", fqdn)
311
- dns_provider.create_challenge(fqdn, validation)
347
+ try:
348
+ dns_provider.create_challenge(fqdn, validation)
349
+ except Exception as exc: # noqa: BLE001
350
+ raise TransientAcmeError(
351
+ f"dns provider failed to create challenge for {fqdn}: {exc}"
352
+ ) from exc
312
353
  dns_challenges.append((fqdn, challb, response, authz, validation))
313
354
  else:
314
355
  if not http01_solver:
@@ -317,7 +358,8 @@ class AcmeClient:
317
358
  log.info("serving http-01 challenge for %s", ident)
318
359
  http01_solver.set(token, validation)
319
360
  http_tokens.append(token)
320
- client.answer_challenge(challb, response)
361
+ with _reraise_transient("answer challenge"):
362
+ client.answer_challenge(challb, response)
321
363
  self._wait_for_authz(client, authz)
322
364
 
323
365
  def _answer_dns_challenges(self, client, dns_challenges, delay: int) -> None:
@@ -329,13 +371,14 @@ class AcmeClient:
329
371
  if not verify_txt_record(
330
372
  fqdn, validation, nameservers=self.recursive_nameservers
331
373
  ):
332
- raise AcmeError(
374
+ raise TransientAcmeError(
333
375
  f"DNS challenge verification failed for {fqdn}: "
334
376
  "TXT record not found at authoritative nameservers"
335
377
  )
336
378
  for fqdn, challb, response, authz, _ in dns_challenges:
337
379
  log.info("answering dns-01 challenge for %s", fqdn)
338
- client.answer_challenge(challb, response)
380
+ with _reraise_transient("answer challenge"):
381
+ client.answer_challenge(challb, response)
339
382
  self._wait_for_authz(client, authz)
340
383
 
341
384
  def _cleanup(
@@ -0,0 +1,27 @@
1
+ """ACME-specific exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..api.errors import DevNomadsError
6
+
7
+
8
+ class AcmeError(DevNomadsError):
9
+ """An ACME issuance step failed."""
10
+
11
+
12
+ class TransientAcmeError(AcmeError):
13
+ """A retry-safe ACME failure (CA unreachable, rate limit,
14
+ propagation timeout, DNS provider outage). The operation may
15
+ succeed if simply retried later; callers should back off and
16
+ retry rather than treat the subject as permanently broken.
17
+
18
+ ``code`` carries the bare ACME error code when one is known
19
+ (e.g. ``"rateLimited"``, ``"serverInternal"``) so callers can
20
+ apply per-code retry policy without string-matching the
21
+ message. None when the failure was not an ACME problem
22
+ document (network error, propagation timeout, ...).
23
+ """
24
+
25
+ def __init__(self, message: str, *, code: str | None = None) -> None:
26
+ super().__init__(message)
27
+ self.code = code
@@ -6,11 +6,12 @@ from types import SimpleNamespace
6
6
 
7
7
  import pytest
8
8
 
9
- from devnomads.acme import AcmeError
9
+ from devnomads.acme import AcmeError, TransientAcmeError
10
10
  from devnomads.acme.client import (
11
11
  _challenge_for_authz,
12
12
  _normalize_challenge_types,
13
13
  _normalize_identifiers,
14
+ _reraise_transient,
14
15
  _split_fullchain,
15
16
  )
16
17
 
@@ -227,3 +228,132 @@ def test_partial_eab_credentials_raise(tmp_path):
227
228
 
228
229
  with pytest.raises(AcmeError):
229
230
  client_mod.AcmeClient(str(tmp_path / "a.pem"), eab_kid="kid-only")
231
+
232
+
233
+ # -- WI-1: transient/structural ACME failure classification ---------------
234
+
235
+
236
+ def test_transient_error_is_acme_error_subclass():
237
+ """Callers that catch AcmeError must still catch the transient variant,
238
+ so the outer 'blacklist everything' net keeps working if a caller has
239
+ not yet special-cased transients."""
240
+
241
+ assert issubclass(TransientAcmeError, AcmeError)
242
+ exc = TransientAcmeError("boom", code="rateLimited")
243
+ assert exc.code == "rateLimited"
244
+ assert TransientAcmeError("boom").code is None
245
+
246
+
247
+ def test_reraise_transient_wraps_request_exception():
248
+ import requests
249
+
250
+ with pytest.raises(TransientAcmeError) as ei:
251
+ with _reraise_transient("directory fetch"):
252
+ raise requests.exceptions.ConnectTimeout("no route to CA")
253
+ assert ei.value.code is None
254
+ assert "directory fetch" in str(ei.value)
255
+
256
+
257
+ @pytest.mark.parametrize("code", ["serverInternal", "rateLimited", "badNonce"])
258
+ def test_reraise_transient_wraps_transient_acme_codes(code):
259
+ from acme import messages
260
+
261
+ with pytest.raises(TransientAcmeError) as ei:
262
+ with _reraise_transient("new order"):
263
+ raise messages.Error.with_code(code)
264
+ assert ei.value.code == code
265
+
266
+
267
+ @pytest.mark.parametrize("code", ["unauthorized", "caa", "rejectedIdentifier"])
268
+ def test_reraise_transient_passes_structural_acme_codes(code):
269
+ """Structural ACME problem documents must escape as plain messages.Error
270
+ (not TransientAcmeError) so the caller still blacklists the domain."""
271
+
272
+ from acme import messages
273
+
274
+ err = messages.Error.with_code(code)
275
+ with pytest.raises(messages.Error) as ei:
276
+ with _reraise_transient("new order"):
277
+ raise err
278
+ assert not isinstance(ei.value, TransientAcmeError)
279
+ assert ei.value is err
280
+
281
+
282
+ @pytest.mark.parametrize("exc_name", ["TimeoutError", "PollError", "NonceError"])
283
+ def test_reraise_transient_wraps_acme_poll_errors(exc_name):
284
+ from acme import errors as acme_errors
285
+
286
+ exc_cls = getattr(acme_errors, exc_name)
287
+ try:
288
+ raised = exc_cls("boom")
289
+ except TypeError:
290
+ # PollError's constructor signature differs across acme versions.
291
+ raised = exc_cls(set(), {})
292
+
293
+ with pytest.raises(TransientAcmeError):
294
+ with _reraise_transient("authorization poll"):
295
+ raise raised
296
+
297
+
298
+ def test_reraise_transient_passes_unrelated_exceptions():
299
+ with pytest.raises(ValueError):
300
+ with _reraise_transient("new order"):
301
+ raise ValueError("not an acme failure")
302
+
303
+
304
+ def _dns01_authz(ident):
305
+ from acme import challenges
306
+
307
+ challb = SimpleNamespace(
308
+ chall=challenges.DNS01(token=b"\x00" * 32),
309
+ response_and_validation=lambda key: (SimpleNamespace(), "validation-string"),
310
+ )
311
+ body = SimpleNamespace(
312
+ identifier=SimpleNamespace(value=ident),
313
+ status=SimpleNamespace(name="pending"),
314
+ challenges=[challb],
315
+ wildcard=False,
316
+ )
317
+ return SimpleNamespace(body=body)
318
+
319
+
320
+ def test_setup_authz_dns_provider_failure_is_transient(tmp_path):
321
+ """A DNS provider outage (API down, bad credentials, network) is an
322
+ infrastructure problem, never a statement about the domain."""
323
+
324
+ from devnomads.acme import client as client_mod
325
+
326
+ class BadProvider:
327
+ def create_challenge(self, fqdn, validation):
328
+ raise RuntimeError("dns api down")
329
+
330
+ ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
331
+ authz = _dns01_authz("example.com")
332
+
333
+ with pytest.raises(TransientAcmeError) as ei:
334
+ ac._setup_authz(
335
+ client=None,
336
+ account_key=None,
337
+ authz=authz,
338
+ types_map={"example.com": "dns-01"},
339
+ dns_provider=BadProvider(),
340
+ http01_solver=None,
341
+ http_tokens=[],
342
+ dns_challenges=[],
343
+ )
344
+ assert "dns provider failed" in str(ei.value)
345
+
346
+
347
+ def test_answer_dns_challenges_txt_timeout_is_transient(monkeypatch, tmp_path):
348
+ """Slow TXT propagation is retry-safe, not a permanent failure."""
349
+
350
+ from devnomads.acme import client as client_mod
351
+
352
+ monkeypatch.setattr(client_mod, "verify_txt_record", lambda *a, **k: False)
353
+
354
+ ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
355
+ dns_challenges = [("example.com", object(), object(), object(), "validation")]
356
+
357
+ with pytest.raises(TransientAcmeError) as ei:
358
+ ac._answer_dns_challenges(client=None, dns_challenges=dns_challenges, delay=0)
359
+ assert "TXT record not found" in str(ei.value)
@@ -468,7 +468,7 @@ wheels = [
468
468
 
469
469
  [[package]]
470
470
  name = "devnomads"
471
- version = "0.2.4"
471
+ version = "0.2.5"
472
472
  source = { editable = "." }
473
473
  dependencies = [
474
474
  { name = "httpx" },
@@ -480,6 +480,7 @@ acme = [
480
480
  { name = "cryptography" },
481
481
  { name = "dnspython" },
482
482
  { name = "josepy" },
483
+ { name = "requests" },
483
484
  ]
484
485
 
485
486
  [package.dev-dependencies]
@@ -502,6 +503,7 @@ requires-dist = [
502
503
  { name = "dnspython", marker = "extra == 'acme'", specifier = ">=2.6" },
503
504
  { name = "httpx", specifier = ">=0.27" },
504
505
  { name = "josepy", marker = "extra == 'acme'", specifier = ">=1.14" },
506
+ { name = "requests", marker = "extra == 'acme'", specifier = ">=2.31" },
505
507
  ]
506
508
 
507
509
  [package.metadata.requires-dev]
@@ -1,9 +0,0 @@
1
- """ACME-specific exceptions."""
2
-
3
- from __future__ import annotations
4
-
5
- from ..api.errors import DevNomadsError
6
-
7
-
8
- class AcmeError(DevNomadsError):
9
- """An ACME issuance step failed."""
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