Approach: Do a dry-run first and validate its result against provided predicates. If dry-run passes, attempt apply with retries using exponential backoff for transient errors. Classify exceptions as retriable or not; return structured status dict for automation.python
import time
import random
class NonRetriableError(Exception):
pass
class TransientError(Exception):
pass
def safe_apply(change_func, dry_run_func, apply_func, max_retries=5, base_delay=1.0, validate=None):
"""
Attempts a dry-run, validates it, then applies the change with retries/backoff.
- change_func: description or callable producing change metadata (optional)
- dry_run_func: callable() -> result (should not mutate)
- apply_func: callable() -> result (performs change)
- max_retries: int
- validate: callable(dry_run_result) -> bool (if None, truthiness used)
Returns dict: {status: 'success'|'failed'|'skipped', reason: str, attempts: int}
"""
# Dry-run
try:
dry_result = dry_run_func()
except NonRetriableError as e:
return {"status":"failed","reason":f"dry-run non-retriable: {e}","attempts":0}
except Exception as e:
return {"status":"failed","reason":f"dry-run error: {e}","attempts":0}
if validate:
ok = False
try:
ok = bool(validate(dry_result))
except Exception as e:
return {"status":"failed","reason":f"validation error: {e}","attempts":0}
if not ok:
return {"status":"skipped","reason":"dry-run validation failed","attempts":0}
# Apply with retries + exponential backoff
attempt = 0
while attempt < max_retries:
attempt += 1
try:
res = apply_func()
return {"status":"success","reason":"applied","attempts":attempt,"result":res}
except NonRetriableError as e:
return {"status":"failed","reason":f"apply non-retriable: {e}","attempts":attempt}
except TransientError as e:
if attempt >= max_retries:
return {"status":"failed","reason":f"transient failures exhausted: {e}","attempts":attempt}
# jittered exponential backoff
sleep = base_delay * (2 ** (attempt - 1))
sleep *= random.uniform(0.8, 1.2)
time.sleep(sleep)
except Exception as e:
# treat unknown exceptions as non-retriable by default
return {"status":"failed","reason":f"unexpected error: {e}","attempts":attempt}
return {"status":"failed","reason":"max retries reached","attempts":attempt}
Key points:- Separate retriable vs non-retriable errors to avoid unsafe retries.- Validate dry-run before applying to prevent unsafe changes.- Exponential backoff with jitter reduces thundering herd.- Returns structured dict for automation to assert outcomes.Complexity: runtime depends on external funcs; overhead O(retries). Edge cases: flaky validation, idempotency of apply_func, long-running backoffs; consider async or circuit-breaker for production. Alternative: integrate a retry library (tenacity) and use metrics/logging for observability.