Skip to content

feat(domains): implement registrar gateway layer with Gandi + ROTLD (#93)#169

Open
b3lz3but wants to merge 2 commits intocaptainpragmatic:masterfrom
b3lz3but:feat/domain-registrar-gateway
Open

feat(domains): implement registrar gateway layer with Gandi + ROTLD (#93)#169
b3lz3but wants to merge 2 commits intocaptainpragmatic:masterfrom
b3lz3but:feat/domain-registrar-gateway

Conversation

@b3lz3but
Copy link
Copy Markdown
Contributor

@b3lz3but b3lz3but commented Apr 6, 2026

Summary

Implements the Better tier from #93 — replaces the stubbed DomainRegistrarGateway with a production-ready gateway abstraction layer for domain registrar API integrations.

What changed

New gateway package (apps/domains/gateways/):

  • errors.pyRegistrarErrorCode enum + typed exception hierarchy (RegistrarAuthError, RegistrarConflictError, RegistrarNotFoundError, RegistrarRateLimitError, RegistrarTransientError)
  • base.pyBaseRegistrarGateway ABC with:
    • Circuit breaker (Django cache, 5 failures = trip, 5 min reset)
    • Idempotency keys for registration/renewal (1 hour TTL)
    • Retry with exponential backoff (3 attempts, 0.5s/1s/2s) for transient errors
    • Audit logging via AuditService.log_simple_event for every API call
    • SSRF-safe HTTP via dedicated OutboundPolicy per registrar
    • Shared HTTP error-to-exception mapping
    • RegistrarGatewayFactory (follows PaymentGatewayFactory pattern)
  • gandi.pyGandiGateway for international domains (.com, .net, .org, .eu) via Gandi REST API
  • rotld.pyROTLDGateway for Romanian .ro domains via ROTLD REST API v2.0, with CUI/CNP registrant mapping

Service layer migration (services.py):

  • DomainLifecycleService methods now return Result[T, str] instead of tuple[bool, T | str]:
    • create_domain_registration()Result[Domain, str]
    • process_domain_renewal()Result[str, str]
    • update_domain_expiration()Result[bool, str]
  • DomainRegistrarGateway is now a backward-compatible facade delegating to the gateway factory
  • All callers updated: views.py, DomainOrderService, tests

Architecture decisions

  • Follows billing gateway pattern (ABC + factory) and cloud gateway pattern (Result[T, E])
  • Uses safe_request() + OutboundPolicy per registrar (matching Virtualmin gateway)
  • Error hierarchy modeled after VirtualminAPIError
  • Err.retriable flag used for transient errors (rate limits, 5xx, network)

Better tier checklist (all complete)

  • Domain-specific error types with RegistrarErrorCode enum
  • Circuit breaker per registrar (Django cache-backed)
  • Idempotency keys for registration/renewal
  • Outbound HTTP security — dedicated OutboundPolicy per registrar
  • Webhook signature verification per registrar (HMAC-SHA256)
  • Audit logging for every registrar API call
  • Result pattern migration — DomainLifecycleService returns Result[T, str]

Test plan

  • 25 new tests in test_registrar_gateways.py — all passing
    • Error types carry correct codes and messages
    • Factory creates correct gateway for gandi/rotld, raises on unknown
    • Gandi: registration (success, auth fail, conflict, rate limit), availability (available, unavailable)
    • ROTLD: registration (success, server error), registrant mapping (company with CUI, individual with CNP)
    • Circuit breaker: blocks at threshold, allows below
    • Idempotency: cached result returned without API call
    • Facade: delegates to factory, returns failure for unknown registrar
  • Existing domain tests unaffected (10/10 still pass)
  • Ruff lint + format clean
  • All 16 pre-commit hooks pass
  • Manual: verify Gandi sandbox registration (blocked on API credentials — Implement domain registrar API integrations #93 dependency)
  • Manual: verify ROTLD test environment (blocked on accreditation — Implement domain registrar API integrations #93 dependency)

Diff stats

  • 5 new files (gateway package + tests): ~1,500 LOC
  • 4 modified files (services, views, existing tests): ~250 LOC changed

Closes #93

🤖 Generated with Claude Code

b3lz3but and others added 2 commits April 2, 2026 17:02
…tainpragmatic#121)

Add retriable: bool = False field to the Err dataclass so callers (e.g.
Django-Q tasks) can distinguish transient errors (DB timeout, lock
contention) from permanent ones (validation failure). Default False
preserves backward compatibility with all 577 existing Err() call sites.

Closes captainpragmatic#121

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
…aptainpragmatic#93)

Replace stubbed DomainRegistrarGateway with a production-ready gateway
abstraction following the billing gateway ABC + cloud gateway Result[T, E]
patterns. Implements the "Better" tier from issue captainpragmatic#93.

New gateway layer (apps/domains/gateways/):
- BaseRegistrarGateway ABC with circuit breaker, idempotency, retry,
  audit logging, SSRF-safe HTTP via OutboundPolicy, and shared error mapping
- GandiGateway for international domains (.com, .net, .org, .eu)
- ROTLDGateway for Romanian .ro domains with CUI/CNP registrant mapping
- RegistrarErrorCode enum + typed exception hierarchy
- RegistrarGatewayFactory following PaymentGatewayFactory pattern

Service layer migration:
- DomainLifecycleService methods now return Result[T, str] instead of
  tuple[bool, T | str], with all callers updated (views, order service, tests)
- DomainRegistrarGateway in services.py is now a backward-compatible facade
  delegating to the gateway factory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
@b3lz3but
Copy link
Copy Markdown
Contributor Author

@mostlyvirtual ready for review 🙏

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.

Implement domain registrar API integrations

1 participant