devnomads 0.2.5__tar.gz → 0.3.1__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.1}/PKG-INFO +2 -1
  2. {devnomads-0.2.5 → devnomads-0.3.1}/pyproject.toml +8 -2
  3. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/__init__.py +2 -1
  4. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/client.py +82 -23
  5. devnomads-0.3.1/src/devnomads/acme/errors.py +27 -0
  6. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_acme_client.py +154 -1
  7. {devnomads-0.2.5 → devnomads-0.3.1}/uv.lock +3 -1
  8. devnomads-0.2.5/src/devnomads/acme/errors.py +0 -9
  9. {devnomads-0.2.5 → devnomads-0.3.1}/.flake8 +0 -0
  10. {devnomads-0.2.5 → devnomads-0.3.1}/.gitignore +0 -0
  11. {devnomads-0.2.5 → devnomads-0.3.1}/.gitlab-ci.yml +0 -0
  12. {devnomads-0.2.5 → devnomads-0.3.1}/Makefile +0 -0
  13. {devnomads-0.2.5 → devnomads-0.3.1}/README.md +0 -0
  14. {devnomads-0.2.5 → devnomads-0.3.1}/openapi.json +0 -0
  15. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/__init__.py +0 -0
  16. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/challenge_server.py +0 -0
  17. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/dns01.py +0 -0
  18. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/http01.py +0 -0
  19. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/keys.py +0 -0
  20. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/acme/verify.py +0 -0
  21. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/__init__.py +0 -0
  22. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/_services.py +0 -0
  23. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/client.py +0 -0
  24. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/credentials.py +0 -0
  25. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/api/errors.py +0 -0
  26. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/__init__.py +0 -0
  27. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/errors.py +0 -0
  28. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/names.py +0 -0
  29. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/dns/zones.py +0 -0
  30. {devnomads-0.2.5 → devnomads-0.3.1}/src/devnomads/py.typed +0 -0
  31. {devnomads-0.2.5 → devnomads-0.3.1}/tests/conftest.py +0 -0
  32. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_acme_challenges.py +0 -0
  33. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_acme_keys.py +0 -0
  34. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_client.py +0 -0
  35. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_credentials.py +0 -0
  36. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_dns.py +0 -0
  37. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_names.py +0 -0
  38. {devnomads-0.2.5 → devnomads-0.3.1}/tests/test_services.py +0 -0
  39. {devnomads-0.2.5 → devnomads-0.3.1}/tools/bump_version.py +0 -0
  40. {devnomads-0.2.5 → devnomads-0.3.1}/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.1
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.1"
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.
@@ -57,6 +88,9 @@ def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str
57
88
  ident = getattr(getattr(authz_body, "identifier", None), "value", None)
58
89
  if ident is None:
59
90
  return None
91
+ # The map is keyed by lowercased identifiers; lowercase the authz value
92
+ # too (belt and braces — CAs already lowercase it) so the lookup matches.
93
+ ident = ident.lower()
60
94
  lookup = f"*.{ident}" if getattr(authz_body, "wildcard", False) else ident
61
95
  return challenge_types_map.get(lookup)
62
96
 
@@ -139,7 +173,8 @@ class AcmeClient:
139
173
  net = acme_client.ClientNetwork(
140
174
  account_key, alg=jws_alg(account_key), user_agent=self.user_agent
141
175
  )
142
- directory = messages.Directory.from_json(net.get(self.directory_url).json())
176
+ with _reraise_transient("directory fetch"):
177
+ directory = messages.Directory.from_json(net.get(self.directory_url).json())
143
178
  client = acme_client.ClientV2(directory, net)
144
179
 
145
180
  registration_kwargs = dict(
@@ -161,14 +196,15 @@ class AcmeClient:
161
196
  new_account = messages.NewRegistration(**registration_kwargs)
162
197
 
163
198
  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)
199
+ with _reraise_transient("account registration"):
200
+ try:
201
+ account = client.new_account(new_account)
202
+ except acme_errors.ConflictError as exc:
203
+ uri = getattr(exc, "location", None) or getattr(
204
+ getattr(exc, "response", None), "headers", {}
205
+ ).get("Location")
206
+ if uri:
207
+ account = messages.RegistrationResource(body=None, uri=uri)
172
208
 
173
209
  if account is None:
174
210
  account = getattr(client.net, "account", None)
@@ -183,13 +219,14 @@ class AcmeClient:
183
219
  def _wait_for_authz(self, client, authz, timeout: int = 300):
184
220
  deadline = time() + timeout
185
221
  while time() < deadline:
186
- authz, _ = client.poll(authz)
222
+ with _reraise_transient("authorization poll"):
223
+ authz, _ = client.poll(authz)
187
224
  if authz.body.status == messages.STATUS_VALID:
188
225
  return authz
189
226
  if authz.body.status == messages.STATUS_INVALID:
190
227
  raise AcmeError(_authz_failure(authz.body))
191
228
  sleep(2)
192
- raise AcmeError("authorization polling timed out")
229
+ raise TransientAcmeError("authorization polling timed out")
193
230
 
194
231
  def _fetch_order_authorizations(self, client, order):
195
232
  raw = getattr(order, "authorizations", None) or []
@@ -235,7 +272,8 @@ class AcmeClient:
235
272
  csr_pem = build_csr(domain_key, identifiers)
236
273
  log.info("requesting certificate for: %s", ", ".join(identifiers))
237
274
 
238
- order = client.new_order(csr_pem)
275
+ with _reraise_transient("new order"):
276
+ order = client.new_order(csr_pem)
239
277
  authzs = self._fetch_order_authorizations(client, order)
240
278
 
241
279
  http_tokens: list[str] = []
@@ -258,9 +296,10 @@ class AcmeClient:
258
296
  client, dns_challenges, dns_propagation_delay
259
297
  )
260
298
 
261
- order = client.poll_and_finalize(
262
- order, deadline=datetime.now() + timedelta(seconds=300)
263
- )
299
+ with _reraise_transient("finalize order"):
300
+ order = client.poll_and_finalize(
301
+ order, deadline=datetime.now() + timedelta(seconds=300)
302
+ )
264
303
  finally:
265
304
  self._cleanup(http01_solver, http_tokens, dns_provider, dns_challenges)
266
305
 
@@ -308,7 +347,12 @@ class AcmeClient:
308
347
  if dns_challenges:
309
348
  sleep(1) # space out API calls to avoid rate limiting
310
349
  log.info("planting dns-01 challenge for %s", fqdn)
311
- dns_provider.create_challenge(fqdn, validation)
350
+ try:
351
+ dns_provider.create_challenge(fqdn, validation)
352
+ except Exception as exc: # noqa: BLE001
353
+ raise TransientAcmeError(
354
+ f"dns provider failed to create challenge for {fqdn}: {exc}"
355
+ ) from exc
312
356
  dns_challenges.append((fqdn, challb, response, authz, validation))
313
357
  else:
314
358
  if not http01_solver:
@@ -317,7 +361,8 @@ class AcmeClient:
317
361
  log.info("serving http-01 challenge for %s", ident)
318
362
  http01_solver.set(token, validation)
319
363
  http_tokens.append(token)
320
- client.answer_challenge(challb, response)
364
+ with _reraise_transient("answer challenge"):
365
+ client.answer_challenge(challb, response)
321
366
  self._wait_for_authz(client, authz)
322
367
 
323
368
  def _answer_dns_challenges(self, client, dns_challenges, delay: int) -> None:
@@ -329,13 +374,14 @@ class AcmeClient:
329
374
  if not verify_txt_record(
330
375
  fqdn, validation, nameservers=self.recursive_nameservers
331
376
  ):
332
- raise AcmeError(
377
+ raise TransientAcmeError(
333
378
  f"DNS challenge verification failed for {fqdn}: "
334
379
  "TXT record not found at authoritative nameservers"
335
380
  )
336
381
  for fqdn, challb, response, authz, _ in dns_challenges:
337
382
  log.info("answering dns-01 challenge for %s", fqdn)
338
- client.answer_challenge(challb, response)
383
+ with _reraise_transient("answer challenge"):
384
+ client.answer_challenge(challb, response)
339
385
  self._wait_for_authz(client, authz)
340
386
 
341
387
  def _cleanup(
@@ -372,7 +418,17 @@ class AcmeClient:
372
418
 
373
419
 
374
420
  def _normalize_identifiers(names: list[str]) -> list[str]:
375
- """IDNA-encode and de-duplicate identifiers, preserving order."""
421
+ """IDNA-encode, lowercase and de-duplicate identifiers, preserving order.
422
+
423
+ DNS names are case-insensitive and ACME servers (Let's Encrypt) echo
424
+ authorization identifiers back lowercased. Python's IDNA codec, however,
425
+ preserves the case of plain-ASCII labels, so a mixed-case config entry
426
+ like ``www.Example.com`` would otherwise key the challenge-type map in a
427
+ case the CA never uses — the challenge lookup at answer time then misses
428
+ and issuance fails with "no challenge type configured". Lowercasing here
429
+ keeps every identifier canonical (punycode output is already lowercase,
430
+ so this is a no-op for internationalised names).
431
+ """
376
432
 
377
433
  identifiers: list[str] = []
378
434
  seen: set[str] = set()
@@ -380,7 +436,7 @@ def _normalize_identifiers(names: list[str]) -> list[str]:
380
436
  if not name:
381
437
  continue
382
438
  try:
383
- ascii_name = name.encode("idna").decode("ascii")
439
+ ascii_name = name.encode("idna").decode("ascii").lower()
384
440
  except Exception: # noqa: BLE001 # nosec B112
385
441
  continue
386
442
  if ascii_name not in seen:
@@ -399,11 +455,14 @@ def _normalize_challenge_types(
399
455
 
400
456
  result: dict[str, str] = {}
401
457
  for ident in identifiers:
458
+ # ``identifiers`` are already IDNA-normalised and lowercased; resolve
459
+ # each against the caller's map case-insensitively so a mixed-case
460
+ # key (``www.Example.com``) still matches its lowercased identifier.
402
461
  ct = challenge_types.get(ident)
403
462
  if ct is None:
404
463
  for key, value in challenge_types.items():
405
464
  try:
406
- if key.encode("idna").decode("ascii") == ident:
465
+ if key.encode("idna").decode("ascii").lower() == ident:
407
466
  ct = value
408
467
  break
409
468
  except Exception: # noqa: BLE001 # nosec B112
@@ -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
 
@@ -50,6 +51,29 @@ def test_challenge_for_authz_wildcard_reprefixes():
50
51
  assert _challenge_for_authz({"*.example.com": "dns-01"}, body) == "dns-01"
51
52
 
52
53
 
54
+ def test_normalize_identifiers_lowercases():
55
+ # DNS is case-insensitive and CAs lowercase identifiers; a mixed-case
56
+ # ASCII label must not survive with its original case (Python's IDNA
57
+ # codec would otherwise preserve it).
58
+ assert _normalize_identifiers(["www.Ahlersbuilding.BE"]) == [
59
+ "www.ahlersbuilding.be"
60
+ ]
61
+
62
+
63
+ def test_mixed_case_challenge_map_resolves_against_lowercased_authz():
64
+ """Regression: a mixed-case config entry keyed the challenge-type map in
65
+ a case the CA never uses, so the challenge lookup missed and issuance
66
+ failed with "no challenge type configured". The map must resolve against
67
+ the lowercased authorization identifier the CA echoes back."""
68
+ # certmonger builds the map keyed by the original (mixed) case:
69
+ challenge_types = {"www.Ahlersbuilding.be": "dns-01"}
70
+ identifiers = _normalize_identifiers(["www.Ahlersbuilding.be"])
71
+ types_map = _normalize_challenge_types(challenge_types, identifiers)
72
+ # LE hands back the identifier lowercased:
73
+ body = _authz("www.ahlersbuilding.be", False)
74
+ assert _challenge_for_authz(types_map, body) == "dns-01"
75
+
76
+
53
77
  def test_split_fullchain():
54
78
  leaf = "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n"
55
79
  rest = "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n"
@@ -227,3 +251,132 @@ def test_partial_eab_credentials_raise(tmp_path):
227
251
 
228
252
  with pytest.raises(AcmeError):
229
253
  client_mod.AcmeClient(str(tmp_path / "a.pem"), eab_kid="kid-only")
254
+
255
+
256
+ # -- WI-1: transient/structural ACME failure classification ---------------
257
+
258
+
259
+ def test_transient_error_is_acme_error_subclass():
260
+ """Callers that catch AcmeError must still catch the transient variant,
261
+ so the outer 'blacklist everything' net keeps working if a caller has
262
+ not yet special-cased transients."""
263
+
264
+ assert issubclass(TransientAcmeError, AcmeError)
265
+ exc = TransientAcmeError("boom", code="rateLimited")
266
+ assert exc.code == "rateLimited"
267
+ assert TransientAcmeError("boom").code is None
268
+
269
+
270
+ def test_reraise_transient_wraps_request_exception():
271
+ import requests
272
+
273
+ with pytest.raises(TransientAcmeError) as ei:
274
+ with _reraise_transient("directory fetch"):
275
+ raise requests.exceptions.ConnectTimeout("no route to CA")
276
+ assert ei.value.code is None
277
+ assert "directory fetch" in str(ei.value)
278
+
279
+
280
+ @pytest.mark.parametrize("code", ["serverInternal", "rateLimited", "badNonce"])
281
+ def test_reraise_transient_wraps_transient_acme_codes(code):
282
+ from acme import messages
283
+
284
+ with pytest.raises(TransientAcmeError) as ei:
285
+ with _reraise_transient("new order"):
286
+ raise messages.Error.with_code(code)
287
+ assert ei.value.code == code
288
+
289
+
290
+ @pytest.mark.parametrize("code", ["unauthorized", "caa", "rejectedIdentifier"])
291
+ def test_reraise_transient_passes_structural_acme_codes(code):
292
+ """Structural ACME problem documents must escape as plain messages.Error
293
+ (not TransientAcmeError) so the caller still blacklists the domain."""
294
+
295
+ from acme import messages
296
+
297
+ err = messages.Error.with_code(code)
298
+ with pytest.raises(messages.Error) as ei:
299
+ with _reraise_transient("new order"):
300
+ raise err
301
+ assert not isinstance(ei.value, TransientAcmeError)
302
+ assert ei.value is err
303
+
304
+
305
+ @pytest.mark.parametrize("exc_name", ["TimeoutError", "PollError", "NonceError"])
306
+ def test_reraise_transient_wraps_acme_poll_errors(exc_name):
307
+ from acme import errors as acme_errors
308
+
309
+ exc_cls = getattr(acme_errors, exc_name)
310
+ try:
311
+ raised = exc_cls("boom")
312
+ except TypeError:
313
+ # PollError's constructor signature differs across acme versions.
314
+ raised = exc_cls(set(), {})
315
+
316
+ with pytest.raises(TransientAcmeError):
317
+ with _reraise_transient("authorization poll"):
318
+ raise raised
319
+
320
+
321
+ def test_reraise_transient_passes_unrelated_exceptions():
322
+ with pytest.raises(ValueError):
323
+ with _reraise_transient("new order"):
324
+ raise ValueError("not an acme failure")
325
+
326
+
327
+ def _dns01_authz(ident):
328
+ from acme import challenges
329
+
330
+ challb = SimpleNamespace(
331
+ chall=challenges.DNS01(token=b"\x00" * 32),
332
+ response_and_validation=lambda key: (SimpleNamespace(), "validation-string"),
333
+ )
334
+ body = SimpleNamespace(
335
+ identifier=SimpleNamespace(value=ident),
336
+ status=SimpleNamespace(name="pending"),
337
+ challenges=[challb],
338
+ wildcard=False,
339
+ )
340
+ return SimpleNamespace(body=body)
341
+
342
+
343
+ def test_setup_authz_dns_provider_failure_is_transient(tmp_path):
344
+ """A DNS provider outage (API down, bad credentials, network) is an
345
+ infrastructure problem, never a statement about the domain."""
346
+
347
+ from devnomads.acme import client as client_mod
348
+
349
+ class BadProvider:
350
+ def create_challenge(self, fqdn, validation):
351
+ raise RuntimeError("dns api down")
352
+
353
+ ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
354
+ authz = _dns01_authz("example.com")
355
+
356
+ with pytest.raises(TransientAcmeError) as ei:
357
+ ac._setup_authz(
358
+ client=None,
359
+ account_key=None,
360
+ authz=authz,
361
+ types_map={"example.com": "dns-01"},
362
+ dns_provider=BadProvider(),
363
+ http01_solver=None,
364
+ http_tokens=[],
365
+ dns_challenges=[],
366
+ )
367
+ assert "dns provider failed" in str(ei.value)
368
+
369
+
370
+ def test_answer_dns_challenges_txt_timeout_is_transient(monkeypatch, tmp_path):
371
+ """Slow TXT propagation is retry-safe, not a permanent failure."""
372
+
373
+ from devnomads.acme import client as client_mod
374
+
375
+ monkeypatch.setattr(client_mod, "verify_txt_record", lambda *a, **k: False)
376
+
377
+ ac = client_mod.AcmeClient(str(tmp_path / "a.pem"))
378
+ dns_challenges = [("example.com", object(), object(), object(), "validation")]
379
+
380
+ with pytest.raises(TransientAcmeError) as ei:
381
+ ac._answer_dns_challenges(client=None, dns_challenges=dns_challenges, delay=0)
382
+ 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.3.0"
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