Skip to content

Commit 710d24f

Browse files
bakerboy448claude
andauthored
Fixed: nextdns_sync prunes stale parentalControl services, exits nonzero on errors (#16)
## Summary parentalControl PATCH never removes services and timed out at 10s on RPM every run; errors were swallowed (exit 0). - parentalControl timeout 10s -> 30s - after PATCH, GET target parentalControl and DELETE services Main lacks (`/profiles/<id>/parentalControl/services/<id>`) - exit 1 at the end if any error was logged (all profiles still attempted) ## Script(s) affected - [x] nextdns_sync.py Tested live: diff + sync via the timer command, exit 0, parentalControl IN SYNC. No secrets in diff. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a736402 commit 710d24f

1 file changed

Lines changed: 56 additions & 3 deletions

File tree

‎nextdns_sync.py‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
# Configuration
99
TIMEOUT = 10
10+
PARENTAL_TIMEOUT = 30
1011
NEXT_DNS_API = "https://api.nextdns.io"
1112
API_PROFILE_ROUTE = "profiles"
1213

@@ -106,6 +107,19 @@ def setup_logger(name: str) -> logging.Logger:
106107
logger = setup_logger(__name__)
107108

108109

110+
class ErrorCounter(logging.Handler):
111+
"""Counts ERROR records so main() can exit nonzero after attempting everything."""
112+
113+
count = 0
114+
115+
def emit(self, record: logging.LogRecord) -> None:
116+
if record.levelno >= logging.ERROR:
117+
ErrorCounter.count += 1
118+
119+
120+
logger.addHandler(ErrorCounter())
121+
122+
109123
def api_request(
110124
method: str,
111125
url: str,
@@ -181,7 +195,11 @@ def alpha_sort_lists(data: Dict) -> Dict:
181195

182196

183197
def update_profile_settings(
184-
profile_id: str, payload: Dict, route: Optional[str] = None, method: str = "PATCH"
198+
profile_id: str,
199+
payload: Dict,
200+
route: Optional[str] = None,
201+
method: str = "PATCH",
202+
timeout: int = TIMEOUT,
185203
) -> requests.Response:
186204
"""Updates the settings for a given profile ID."""
187205
logger.info(
@@ -192,7 +210,9 @@ def update_profile_settings(
192210
raise ValueError(
193211
"[UPDATE-PROFILE] Payload cannot be None. Please provide a valid payload."
194212
)
195-
return api_request(method, url, headers=HEADERS, json=payload)
213+
return api_request(
214+
method, url, headers=HEADERS, json=payload, timeout=timeout
215+
)
196216

197217

198218
def update_array_settings(
@@ -451,6 +471,23 @@ def diff_profiles() -> None:
451471
print()
452472

453473

474+
def prune_parental_services(profile_id: str, source_services: List[Dict]) -> None:
475+
"""Deletes parentalControl services on a target that Main lacks (PATCH never removes them)."""
476+
keep = {s.get("id") for s in source_services}
477+
url = f"{NEXT_DNS_API}/{API_PROFILE_ROUTE}/{profile_id}/parentalControl"
478+
current = api_request("GET", url, headers=HEADERS, timeout=PARENTAL_TIMEOUT)
479+
for svc in current.json().get("data", {}).get("services", []):
480+
sid = svc.get("id")
481+
if sid and sid not in keep:
482+
api_request(
483+
"DELETE",
484+
f"{url}/services/{sid}",
485+
headers=HEADERS,
486+
timeout=PARENTAL_TIMEOUT,
487+
)
488+
logger.info("[SYNC] Removed stale parental service %s from %s", sid, profile_id)
489+
490+
454491
def sync_profiles(keys_to_sync: List[str], payload: Optional[Dict] = None) -> None:
455492
"""Syncs settings from the main profile to the target profiles."""
456493
try:
@@ -481,7 +518,20 @@ def sync_profiles(keys_to_sync: List[str], payload: Optional[Dict] = None) -> No
481518
if isinstance(key_payload, list):
482519
update_array_settings(profile_id, key, key_payload)
483520
else:
484-
update_profile_settings(profile_id, key_payload, key)
521+
update_profile_settings(
522+
profile_id,
523+
key_payload,
524+
key,
525+
timeout=(
526+
PARENTAL_TIMEOUT
527+
if key == "parentalControl"
528+
else TIMEOUT
529+
),
530+
)
531+
if key == "parentalControl":
532+
prune_parental_services(
533+
profile_id, key_payload.get("services", [])
534+
)
485535
logger.info(
486536
"[SYNC] Successfully updated %s for profile %s.",
487537
key,
@@ -548,6 +598,9 @@ def main():
548598
elif args.action == "diff":
549599
diff_profiles()
550600

601+
if ErrorCounter.count:
602+
logging.error("Finished with %d error(s)", ErrorCounter.count)
603+
raise SystemExit(1)
551604
logging.info("Done.")
552605

553606

0 commit comments

Comments
 (0)