Skip to content

fix(EVBL-47): fail fast on terminal pending-cert failures to avoid CA rate limit - #368

Closed
evan-datadog wants to merge 12 commits into
masterfrom
evan/EVBL-47/le-pending-cert-fail-fast
Closed

fix(EVBL-47): fail fast on terminal pending-cert failures to avoid CA rate limit#368
evan-datadog wants to merge 12 commits into
masterfrom
evan/EVBL-47/le-pending-cert-fail-fast

Conversation

@evan-datadog

Copy link
Copy Markdown

Problem

When dc boot issues a create-cert request, Lemur creates a PendingCertificate and auto-triggers fetch_acme_cert. On failure, fetch_acme_cert re-queues itself (up to ACME_ADDITIONAL_ATTEMPTS times), and each retry creates a new ACME order and re-runs the DNS-01 challenge against the CA.

For a deterministic failure — broken DNS delegation, no DNS provider configured, or invalid ACME credentials — retrying can never succeed. It just keeps creating orders / failed validations until it exhausts the CA's rate limit (Let's Encrypt: 5 duplicate certificates / failed validations per week per domain). What should be a single failure becomes repeated issuance attempts that block the DC from ever getting its certs.

Fix

Add pending_certificate_service.is_terminal_failure(error) that classifies configuration / DNS-delegation / credential failures as terminal, and use it in both the celery task (fetch_acme_cert) and the pending-cert CLI to mark the pending certificate resolved immediately instead of re-queueing.

  • Terminal (fail fast, no re-queue): InvalidConfiguration, "No DNS providers found for domain", "Unable to determine DNS challenges", auth/credential errors (unauthorized / authentication / invalid account / 401 / 403).
  • Transient (keep existing bounded retry): DNS verification / order-not-ready failures that may resolve on retry (e.g. DNS propagation).

Tests

test_pending_certificates.pyis_terminal_failure covers: InvalidConfiguration, no-DNS-provider, no-DNS-challenges, auth (401/403/unauthorized) → True; transient (Failed verification / order not ready) → False.

Notes

  • EVBL-47. Complements (but is independent of) the client-side idempotency change in dd-source PR #56764.

… rate limit

A pending certificate that fails with a terminal error (broken DNS
delegation, no DNS provider, or invalid ACME credentials) was re-queued
by fetch_acme_cert up to ACME_ADDITIONAL_ATTEMPTS times, each retry
creating a new ACME order and re-running the DNS-01 challenge against the
CA. For deterministic config/delegation failures this can never succeed
and only burns the CA's rate limit (Let's Encrypt: 5 duplicate
certificates / failed validations per week per domain).

Add pending_certificate_service.is_terminal_failure() and use it in
fetch_acme_cert (celery) and the pending-cert CLI to mark such pending
certificates resolved immediately instead of re-queueing, while keeping
bounded retries for genuinely transient failures (e.g. DNS propagation).

Workspace: local
@evan-datadog
evan-datadog requested review from a team as code owners August 14, 2026 20:58
…li cert.id, add e2e tests

Review-swarm findings addressed:
- is_terminal_failure now matches markers case-insensitively (e.g. UNAUTHORIZED).
- cli.py used cert.id on a dict (AttributeError); switch to pending_cert.id in both
  the terminal and attempts-exhausted branches, consistent with celery.py.
- Add end-to-end tests for fetch_acme_cert: terminal failure marks resolved and does
  not re-queue; transient failure increments attempts and re-queues.

Workspace: local
Protocol: deploy to staging, create a pending cert that fails terminally (no DNS
provider / broken delegation), and confirm in the logs it is attempted only once
and marked resolved (no re-queue / no repeated ACME order), plus a negative
control that transient failures still retry.

Workspace: local
@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented Aug 14, 2026

Copy link
Copy Markdown

Pipelines  Tests

⚠️ Warnings

🚦 1 Pipeline job failed

DataDog/lemur | build-stage-image — 🔄 Retry may pass, looks flaky

View in Datadog · View in GitLab

ℹ️ Info

🔄 Datadog auto-retried 1 job - 0 passed on retry View in Datadog

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: c9f0ba9 | Docs | View more details | Give us feedback!

g.current_user can be a detached/expired SQLAlchemy User in some contexts
(e.g. tests), so accessing .email raises DetachedInstanceError. Fall back
to the anonymous LEMUR label instead of crashing. Fixes flaky CI failures
in test_pending_certificates.py.

Workspace: local
… flakiness

g is app-context scoped and persists across tests, so a User set by an
authenticated request in one test leaks into later tests as a stale,
detached (and expired) instance. Accessing its attributes then raises
DetachedInstanceError in audit_log, certificate schema serialization, etc.
Clear g.current_user in the session fixture teardown so each test starts
clean.

Workspace: local
Comment thread lemur/pending_certificates/service.py Outdated
# re-runs the ACME order/challenge and burns the CA's rate limit (Let's Encrypt:
# 5 duplicate certificates / failed validations per week per domain), so they must
# fail fast and mark the pending certificate resolved instead of retrying.
_TERMINAL_FAILURE_MARKERS = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we model terminal failures with explicit exception types raised at the ACME/DNS boundaries? get_ordered_certificates should preserve the exception object, and the retry code can use isinstance(error, PendingCertificateTerminalError). For upstream HTTP errors, we can translate structured 401/403 status codes into an ACMEAuthenticationError. This avoids treating mutable exception text as an API and makes adding new terminal cases intentional and testable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread lemur/pending_certificates/service.py Outdated
# 5 duplicate certificates / failed validations per week per domain), so they must
# fail fast and mark the pending certificate resolved instead of retrying.
_TERMINAL_FAILURE_MARKERS = (
"no dns providers found for domain",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you sure that's always terminal, what about delegated CNAMES

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no delegated CNAME we still want to fail fast instead of retrying over and over and exausting the rate limit

…ion types

Replace string-matching on mutable exception text with an explicit exception
hierarchy raised at the ACME/DNS boundaries:

- PendingCertificateTerminalError base + ACMEAuthenticationError,
  NoDNSProviderError, DNSChallengeSetupError subclasses (InvalidConfiguration
  re-parented under the base).
- acme_handlers raises NoDNSProviderError / DNSChallengeSetupError instead of
  bare Exception at the DNS-01 boundary.
- get_ordered_certificates preserves the exception object in last_error and
  classifies it via _classify_pending_error, translating upstream HTTP 401/403
  into ACMEAuthenticationError (requests.HTError / botocore ClientError /
  acme.messages.Error status extraction).
- fetch_acme_cert / cli use isinstance(last_error, PendingCertificateTerminalError)
  for the fail-fast decision. is_terminal_failure keeps the string-marker
  fallback only for pre-existing stored status text.

Adding a new terminal case is now a subclass + raise at the boundary,
intentional and unit-testable, instead of a brittle substring match.

Workspace: local
Comment thread lemur/tests/test_pending_cert_retry.py Outdated

import lemur.common.celery as _celery_module # noqa: E402

_celery_module.current_app = MagicMock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This globally replaces lemur.common.celery.current_app and never restores it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with _mock_celery_current_app

Comment thread lemur/common/celery.py
…ore-notify, test fixture

- get_authorizations: when ACME_ENABLE_DELEGATED_CNAME is on but the _acme-challenge
  CNAME isnt resolving yet, raise a transient ValueError instead of a terminal
  NoDNSProviderError, so DNS-propagation delays dont burn the CA rate limit.
- fetch_acme_cert / cli: persist status=str(last_error) and resolved=True BEFORE
  sending the failure notification, so a notification-delivery error cant leave the
  pending cert unresolved (and retried).
- test_pending_cert_retry: replace the module-scope current_app mock with an autouse
  fixture that restores it on teardown; add persist-before-notify and typed-error tests.
- test_acme_dns: cover delegated-CNAME-pending (transient) vs no-provider (terminal).

Workspace: local
…thing deployed)

_TERMINAL_FAILURE_MARKERS was introduced by this PR, so there is no deployed
state to be backward-compatible with. Remove the string-matching fallback and
classify terminal failures purely by exception type:

- is_terminal_failure is now a thin isinstance(error, PendingCertificateTerminalError).
- fetch_acme_cert / cli use is_terminal_failure as the single source of truth
  (no isinstance-or-string fallback).
- Update tests to use the typed exceptions (NoDNSProviderError, DNSChallengeSetupError,
  ACMEAuthenticationError) instead of terminal-looking plain Exception strings.
- Remove the now-obsolete case-insensitive string-marker test.

Workspace: local
…ted CNAME

Revert the earlier transient ValueError for a not-y-resolving delegated CNAME.
If there is no delegated DNS provider (the CNAME does not exist), retrying can
never succeed and only re-exhausts the CA rate limit, so it must fail fast as a
terminal NoDNSProviderError. The delegated-CNAME path already resolves the
provider on the CNAME target when the CNAME exists; the no-provider case is
terminal in all configurations.

Update test_get_authorizations_delegated_cname_no_provider_terminal to assert
NoDNSProviderError.

Workspace: local
@evan-datadog
evan-datadog requested a review from maperu August 18, 2026 14:54
…tal attempts

The bounded retry previously ran ACME_ADDITIONAL_ATTEMPTS + 1 times (4 total
attempts with the default of 2), because the give-up condition was
number_attempts > ACME_ADDITIONAL_ATTEMPTS. Each retry re-runs the ACME
order/challenge, so a persistently-failing cert burned nearly the whole
Let's Encrypt rate limit (5 duplicate certs / failed validations per week).

Change the give-up condition to number_attempts >= ACME_ADDITIONAL_ATTEMPTS,
so a cert is attempted ACME_ADDITIONAL_ATTEMPTS + 1 times total (3 with the
default) — the initial attempt plus ACME_ADDITIONAL_ATTEMPTS retries. This
also makes the constant's name match its behavior (additional attempts beyond
the initial one).

Workspace: local
evan-datadog added a commit that referenced this pull request Aug 18, 2026
… flakiness

g is app-context scoped and persists across tests, so a User set by an
authenticated request in one test leaks into later tests as a stale, detached
(and expired) instance. Accessing its attributes then raises DetachedInstanceError
in audit_log / certificate schema serialization. Clear g.current_user in the
session fixture teardown so each test starts clean.

This is needed for CI to pass reliably on master-based branches (same fix as
PR #368).

Workspace: local
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants