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.
- {devnomads-0.2.5 → devnomads-0.3.0}/PKG-INFO +2 -1
- {devnomads-0.2.5 → devnomads-0.3.0}/pyproject.toml +8 -2
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/__init__.py +2 -1
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/client.py +63 -20
- devnomads-0.3.0/src/devnomads/acme/errors.py +27 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_acme_client.py +131 -1
- {devnomads-0.2.5 → devnomads-0.3.0}/uv.lock +3 -1
- devnomads-0.2.5/src/devnomads/acme/errors.py +0 -9
- {devnomads-0.2.5 → devnomads-0.3.0}/.flake8 +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/.gitignore +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/.gitlab-ci.yml +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/Makefile +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/README.md +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/openapi.json +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/__init__.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/keys.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/acme/verify.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/_services.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/client.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/credentials.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/src/devnomads/py.typed +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/conftest.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_acme_keys.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_client.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_credentials.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_dns.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_names.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tests/test_services.py +0 -0
- {devnomads-0.2.5 → devnomads-0.3.0}/tools/bump_version.py +0 -0
- {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.
|
|
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.
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
getattr(exc, "
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
order
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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]
|
|
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
|
|
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
|
|
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
|