Skip to content

fix(unifi): best-effort email + per-user resilient apply (CODE_ADMIN_EMAIL_EXIST crash) - #32

Merged
RyanMorash merged 3 commits into
mainfrom
fix/email-collision-resilient-apply
Jun 29, 2026
Merged

fix(unifi): best-effort email + per-user resilient apply (CODE_ADMIN_EMAIL_EXIST crash)#32
RyanMorash merged 3 commits into
mainfrom
fix/email-collision-resilient-apply

Conversation

@RyanMorash

@RyanMorash RyanMorash commented Jun 29, 2026

Copy link
Copy Markdown
Member

The crash

A live sync crashed with CODE_ADMIN_EMAIL_EXIST: Email address is already registered:

unifi.apply → _apply_update_credential → _request → _unwrap
UnifiClientError: CODE_ADMIN_EMAIL_EXIST

UniFi Access requires emails to be globally unique across users AND admins. Several staff are both UniFi admins and CiviCRM members, so writing a member's CiviCRM email (already registered to their admin account) is rejected. Nothing caught it, so the exception propagated out of apply() and crashed the whole reconcile cycle — and because the collision is persistent, every cycle re-crashed, leaving sync stuck in a crash/alert loop (and the bundled name/card update for that user never applied).

Fix (both behaviors confirmed with the maintainer)

1. Email is best-effort. UnifiClientError now carries the envelope code. A new _request_user_write helper — used by all three email-writing paths (create, reactivate, credential-update) — catches an *_EMAIL_EXIST code, retries the write without user_email, and logs a warning. Name/card/policy still apply; the member keeps door access; only the conflicting email isn't synced. These are not counted as failures, so they don't alert (expected for admin-members).

2. Per-user resilient apply. Each contact's apply step is wrapped: a UnifiClientError is logged + recorded and the cycle continues with the rest, then apply() raises one summary error so the orchestrator still alerts on genuine failures. One bad record never blocks the others. (The batch card pre-import stays fail-fast as a shared prerequisite.) Extracted _apply_one_credential to keep the loop readable.

Tests

  • Email-conflict retry on the credential-update and create paths (asserts the retry drops user_email and the rest applies + warns).
  • _is_email_conflict unit test (matches ADMIN/USER EMAIL_EXIST, nothing else).
  • Per-user isolation: contact 1's policy PUT fails → contact 2 still runs → apply() raises a summary naming contact 1.

Verification

  • uv run pytest347 passed
  • uv run pyrefly check → 0 errors
  • uv run ruff check . / ruff format --check . → clean
  • sphinx-build -W → clean

Note / possible follow-up

For an admin-member whose email never syncs, the reconciler keeps seeing the email as "differing" and will attempt (then best-effort-skip) it every cycle, emitting the warning each time. Harmless but recurring. Suppressing that re-diff would need state ("email is unsyncable for this contact") — happy to do it as a follow-up if the recurring warnings are noisy.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Reconciliation now continues when one contact fails, so other contacts still sync and a summary error is reported afterward.
    • Email updates are now best-effort: if an address is already in use, other profile changes still apply and the conflicting email is skipped.
  • Documentation
    • Updated architecture and sync behavior docs to reflect the latest error-handling and email-sync rules.
  • Tests
    • Added coverage for email-conflict handling and per-contact failure isolation.

Live sync crashed with CODE_ADMIN_EMAIL_EXIST: UniFi requires globally
unique emails across users AND admins, and several staff are both UniFi
admins and CiviCRM members. Writing a member's email that already belongs
to an admin was rejected, and nothing caught it — so the whole reconcile
cycle crashed, every cycle (the collision is persistent), leaving sync
stuck in a crash/alert loop.

- Email best-effort: UnifiClientError now carries the envelope `code`.
  _request_user_write (used by create, reactivate, and credential-update)
  retries the write without `user_email` on an *_EMAIL_EXIST code and warns;
  the rest of the record still applies. Not treated as a failure.
- Per-user resilience: each contact's apply step is wrapped; a
  UnifiClientError is logged + recorded and the cycle continues with the
  rest, then apply() raises one summary so the orchestrator still alerts.
  (Batch card pre-import stays fail-fast.) Extracted _apply_one_credential.

Docs: architecture.md §7 documents both behaviors.

347 passed; pyrefly 0 errors; ruff + sphinx -W clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 29, 2026 21:06
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@RyanMorash, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bf02e823-d065-4be4-bb47-2d3e55b9f19d

📥 Commits

Reviewing files that changed from the base of the PR and between e263703 and b1340e6.

📒 Files selected for processing (2)
  • src/door_sync/unifi/client.py
  • tests/test_unifi_client.py
📝 Walkthrough

Walkthrough

UnifiClientError gains an optional code field propagated from UniFi API envelope responses. UnifiClient.apply() is refactored to isolate per-contact failures using _apply_failures tracking and a new _request_user_write() helper that retries writes without user_email on EMAIL_EXIST conflicts. All write paths (_apply_one_credential, _apply_update_policy, _apply_deactivate, _apply_add, _prepare_reactivation, _create_user) adopt the new helpers. Architecture docs and spec are updated to document these semantics.

Changes

Per-contact failure isolation and best-effort email writes

Layer / File(s) Summary
UnifiClientError code field and conflict detection
src/door_sync/unifi/client.py
UnifiClientError accepts a new code: str | None kwarg stored on the instance; _unwrap() populates it from the envelope; _apply_failures list initialized; _is_email_conflict() detects EMAIL_EXIST suffix.
apply() orchestration, _record_apply_failure, and _request_user_write
src/door_sync/unifi/client.py
apply() resets _apply_failures, runs the step sequence, and raises a single summary UnifiClientError if any failures were recorded. _record_apply_failure() logs and accumulates per-contact failure strings. _request_user_write() retries writes by dropping user_email on email-conflict codes.
Per-contact isolation in all write paths
src/door_sync/unifi/client.py
_apply_one_credential() consolidates credential update logic using _request_user_write(); _apply_update_policy(), _apply_deactivate(), and _apply_add() wrap per-contact operations in try/except to record failures and continue; _prepare_reactivation() and _create_user() switch to _request_user_write().
Tests for new behavior
tests/test_unifi_client.py
New tests cover _is_email_conflict recognition, credential update retry without user_email, per-user failure isolation with summary raise, and create-user retry without user_email.
Architecture docs and spec
docs/architecture.md, docs/architecture/conventions.rst, docs/architecture/reconciliation.rst, docs/superpowers/specs/2026-06-17-sync-member-email-design.md
Per-contact isolation and best-effort email write semantics documented across architecture reference and design spec.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 Hoppity-hop through the email maze,
One conflict code, a gentle glaze—
Drop the email, retry with care,
The other fields still get their share.
No one user blocks the rest,
Best-effort writes pass every test! ✉️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main UniFi fix: best-effort email writes and resilient per-user apply handling for the email-exist crash.
Docstring Coverage ✅ Passed Docstring coverage is 81.48% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/email-collision-resilient-apply

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-code-quality

github-code-quality Bot commented Jun 29, 2026

Copy link
Copy Markdown

Code Coverage Overview

Languages: Python

Python / code-coverage/pytest

The overall coverage remains at 93%, unchanged from the main branch.


Updated June 29, 2026 21:24 UTC
Code Coverage is in Public Preview. Learn more and provide us with your feedback.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the UniFi Access reconciliation loop against persistent email-collision errors and ensures a single bad user record cannot crash an entire sync cycle.

Changes:

  • Extend UnifiClientError to carry the UniFi envelope code, enabling targeted recovery logic.
  • Add best-effort email writes via _request_user_write, retrying without user_email on *_EMAIL_EXIST conflicts.
  • Isolate per-contact apply failures, continuing the cycle while collecting failures and raising a summary error at the end; add tests and update architecture docs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/door_sync/unifi/client.py Adds per-user failure isolation, best-effort email syncing with conflict retry, and propagates UniFi error codes via UnifiClientError.
tests/test_unifi_client.py Adds regression tests for email-conflict retry behavior and for per-user isolation + summary failure reporting.
docs/architecture.md Documents the new per-user isolation behavior and the best-effort email sync semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/door_sync/unifi/client.py
Comment thread src/door_sync/unifi/client.py Outdated
Beyond the architecture.md §7 note already in this PR, update the other
docs that described the old apply/error behavior:

- conventions.rst "Error Handling": note the two apply() refinements
  (per-user isolation + best-effort email) under the Clients layer.
- reconciliation.rst: clarify the "exceptions propagate" property — apply()
  isolates a single contact's failure and raises a summary; email is
  best-effort.
- 2026-06-17-sync-member-email-design.md: dated correction that email
  writes are best-effort (UniFi's global email-uniqueness constraint, the
  CODE_ADMIN_EMAIL_EXIST case, and the retry-without-email behavior).

Docs build clean under sphinx-build -W.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_unifi_client.py (1)

1559-1566: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the retried create payload keeps the required non-email fields.

This test only proves user_email is removed on retry. If the retry body accidentally dropped employee_number or the split name fields too, the mock would still return success and this would pass. Please decode the second POST body and assert it still contains the expected create payload minus user_email.

Suggested change
     assert len(posts) == 2
     assert "user_email" in _json.loads(posts[0].content)
-    assert "user_email" not in _json.loads(posts[1].content)
+    retry_body = _json.loads(posts[1].content)
+    assert "user_email" not in retry_body
+    assert retry_body == {
+        "first_name": "Jane",
+        "last_name": "Doe",
+        "employee_number": "42",
+    }
     assert any("email already registered" in r.message for r in caplog.records)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_unifi_client.py` around lines 1559 - 1566, The retry assertion in
the create-user test only checks that user_email is removed, so it can miss
regressions where other required fields are lost. Update the test around the
POST capture in test_unifi_client.py to decode the second request body and
assert it still includes the expected create payload fields, especially
employee_number and the split name fields, while only omitting user_email. Use
the existing httpx_mock.get_requests() POST filtering and _json.loads on the
second request body to compare the full payload shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/door_sync/unifi/client.py`:
- Around line 428-436: The per-contact failure logging in _record_apply_failure
currently records only the error message and loses the traceback context. Update
the logger.error call in UnifiClient._record_apply_failure to pass exc_info=exc
so the original stack is preserved even outside the active except block, while
keeping the existing concise _apply_failures summary append unchanged.

In `@tests/test_unifi_client.py`:
- Line 1253: The tests creating UnifiClient instances via make_client are
leaving clients open, which can leak sockets/FDs. Update the affected test
blocks to use a context manager (for example, with make_client(...) as client:)
or ensure client.close() runs in a finally block. Apply this to each affected
test that currently assigns client directly, so the cleanup is explicit and not
dependent on fixture teardown.

---

Nitpick comments:
In `@tests/test_unifi_client.py`:
- Around line 1559-1566: The retry assertion in the create-user test only checks
that user_email is removed, so it can miss regressions where other required
fields are lost. Update the test around the POST capture in test_unifi_client.py
to decode the second request body and assert it still includes the expected
create payload fields, especially employee_number and the split name fields,
while only omitting user_email. Use the existing httpx_mock.get_requests() POST
filtering and _json.loads on the second request body to compare the full payload
shape.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4169e847-bf08-4a56-9499-51b5bd065d85

📥 Commits

Reviewing files that changed from the base of the PR and between a7fe67f and e263703.

📒 Files selected for processing (6)
  • docs/architecture.md
  • docs/architecture/conventions.rst
  • docs/architecture/reconciliation.rst
  • docs/superpowers/specs/2026-06-17-sync-member-email-design.md
  • src/door_sync/unifi/client.py
  • tests/test_unifi_client.py

Comment thread src/door_sync/unifi/client.py
Comment thread tests/test_unifi_client.py
…d summary)

Address PR #32 review:
- _request_user_write: sleep the inter-call delay before the email-conflict
  retry so a burst of EMAIL_EXIST conflicts doesn't fire back-to-back
  requests (Copilot).
- _record_apply_failure: log with exc_info=exc to preserve the traceback,
  per the project's crash-logging convention (CodeRabbit).
- Cap the apply-failure summary at 10 detailed entries + "...and N more"
  via _format_apply_failure_summary, so a widespread failure can't produce
  an unbounded error/alert message (Copilot). Add unit tests.

(Declined the suggestion to wrap test clients in `with` — the make_client
fixture already closes every client at teardown, matching all 67 existing
usages.)

349 passed; pyrefly 0 errors; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@RyanMorash
RyanMorash merged commit f01d322 into main Jun 29, 2026
6 checks passed
@RyanMorash
RyanMorash deleted the fix/email-collision-resilient-apply branch June 29, 2026 21:54
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