fix(domains): report each added domain's outcome instead of aborting on the first conflict - #1691
Open
dawsontoth wants to merge 1 commit into
Open
fix(domains): report each added domain's outcome instead of aborting on the first conflict#1691dawsontoth wants to merge 1 commit into
dawsontoth wants to merge 1 commit into
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces sequential domain addition logic to handle multiple domains, manage failures, and reconcile results against a refreshed list, with comprehensive test coverage. It refactors the DomainsManagement component to utilize these utilities, improving error handling, form state management, and offline resilience. A review comment points out that deduplicating errors solely by HTTP status in reportUnexpectedFailures could silently swallow critical client-side errors with an undefined status, and suggests using a more specific deduplication key like the error message.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
dawsontoth
force-pushed
the
fix/add-domain-conflict-inline
branch
5 times, most recently
from
September 8, 2026 20:17
d81ca3c to
eb33ea5
Compare
…on the first conflict `onSubmitClick` awaited `addDomain` with no error handling, so a rejected add — a 409 on a domain the org already has — left two defects behind. react-hook-form re-throws whatever `handleSubmit`'s callback throws (`react-hook-form@7.86.0`, `dist/index.esm.mjs:3222`), so the rejection escaped the DOM submit handler as an unhandled promise rejection. RUM recorded 11 `handling:unhandled` events in 24h alongside the 11 handled ones the global toast already produced. The loop also aborted on the first rejection, discarding the domains created before it: `refetch()`, `form.reset()` and the success toast were all skipped, so the table never showed the new records. Because the "Add www as well" button writes `example.com, www.example.com` into the field in one click, a partial failure was the normal outcome for an apex domain — and resubmitting the whole string then conflicted on the entry that had succeeded. Sessions retried 2-5 times before anything stuck. Now every entry is attempted and both halves come back, so the caller commits what succeeded and states what did not beside the input; the mutation opts out of the global toast, as the auth hooks do. Five details the sequence made load-bearing: - A failure is excused only by evidence the submit *created* the domain: a name the refreshed list has and a pre-submit snapshot did not. Presence alone cannot tell "this POST committed" from "the org already owned it", and the org list necessarily contains a name the server rejected as a duplicate. An absent snapshot (list still loading) credits nothing, since it is indistinguishable from an org that owns nothing. - Only an indeterminate failure is arbitrated that way — no response at all, or 408/502/503/504, where a gateway in front of central-manager can answer after the row committed. Any other status is the origin saying nothing was written. - Add is gated on `form.formState.isSubmitting`, not the mutation's `isPending`. The latter drops when the last add settles, while the refetch still runs with the submitted text in the field — a window this reordering introduces. - At most one Error Tracking event per submit, and only for a failure nobody expects. A 409 is the user naming a domain the org owns and the form says so inline; a 5xx or a network failure must not be hidden behind an earlier conflict. Per-entry logging would let a paste of a dozen owned domains bury real signal — the class behind #1371, #1386 and #1645. - The parsed list is capped and deduped case-insensitively. Attempting every entry removes the fail-fast loop's accidental circuit breaker, so a stray paste would otherwise fire one POST per whitespace-delimited token; and central-manager's `addDomain` only rejects a duplicate that is already ACTIVE, so `Example.com, example.com` would create two PENDING_VALIDATION rows and two DNS challenges for one identity. Closes #1685
dawsontoth
force-pushed
the
fix/add-domain-conflict-inline
branch
from
September 8, 2026 20:36
eb33ea5 to
0765c0e
Compare
dawsontoth
marked this pull request as ready for review
September 8, 2026 21:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adding a domain on the cluster Domains page reported a conflict twice to Error Tracking and threw away work it had already done. RUM for 2026-09-08: 11 of 11
POST /Domain/409s in 30 days landed in a single day (against 7 creations in that whole month), each session retrying 2–5× at 4–13 second intervals before anything succeeded — and every 409 produced two events, onehandling:handledand onehandling:unhandled.The two defects
onSubmitClickawaitedaddDomainwith no error handling.The rejection escaped the submit handler. react-hook-form re-throws whatever
handleSubmit's callback throws — verified in the installed dist,react-hook-form@7.86.0,dist/index.esm.mjs:3222:so it left the DOM submit handler as an unhandled promise rejection, i.e. crash-class signal in Error Tracking for something the app had already handled with a toast one frame earlier.
Partial progress was silently discarded. The loop aborted on the first rejection, so
refetch(),form.reset()and the success toast were all skipped and the table never showed the domains that had been created. This is not a hypothetical multi-domain path: the Add www as well button rewrites the field toexample.com, www.example.comin one click, so a partial failure is the normal outcome for an apex domain — and resubmitting the whole string then conflicted on the entry that had just succeeded.For the human reviewer
Five judgment calls the review surfaced. All are cheap to reverse; I'd rather you overrule one than not see it.
1. The batch stops on anything that is not a per-name refusal, and continues past a 4xx. A degraded or unreachable server answers the next 19 names the same way and each can cost the full 60s
apiClienttimeout — twenty minutes of a locked form to learn what the first one said, where the old fail-fast loop stopped after one. A per-name refusal — 400/403/404/409/422 — is about that name and says nothing about the next, so those continue. A 401 and a 429 do not: the 401 has already cleared auth and started a redirect (unauthorizedResponseHandler), so the rest would POST unauthenticated, and 19 immediate retries are the one thing not to send a limiter that just refused this client. Un-attempted names come back markedNot attempted.so none is silently dropped from the retry input; they are excluded from telemetry, and anattempted: falseentry can never be excused by the list — this submit never sent it, so the name appearing there is somebody else's write. The abort predicate is deliberately the sameisIndeterminateused for excusal, so the two cannot drift apart.2. The reconciliation is bounded and skippable, because it can only ever excuse a failure. It is raced against a 4s window: the shared query client sets no
retry, so React Query's default of 3 attempts at up to the 60s client timeout each would hold the inline message for minutes on exactly the timed-out POST that needs reconciling. A window that expires credits nothing — the same conservative direction as every other branch here. It is also skipped outright when it cannot change anything: 2b. A submit whose failures are all determinate changed nothing server-side, so waiting on the list (with its retry backoff) would only delay the inline message and keep the form locked. It runs when something was added or a failure is indeterminate.3. Offline is handled on
onlineManager, notnavigator.onLine. The mutation setsnetworkMode: 'always', because React Query's default pauses a mutation beforemutationFnruns whileonlineManagerreports offline —mutateAsyncnever settles, the sequential loop hangs on its first await with the form locked and no message, and the queued POST later fires for a submit the user abandoned. Letting it run means axios rejectsERR_NETWORKat once and the caller reports it inline. The reconcile skip gates ononlineManager.isOnline()for the same reason: it is the signal React Query pauses on, so it is exactly the condition where asking would burn the window instead of answering. A client timeout is not this case — the server was reached and may have committed — so that still reconciles. Every control the settle path rewrites — the input, Add, and "Add www as well" — shares oneformLockedgate, which also covers the first load of the domain list: without a pre-submit list nothing can be credited, so a POST that commits then times out in that window would invite a retry creating the secondPENDING_VALIDATIONrow the counts machinery exists to prevent. A failed load deliberately unlocks the form — crediting stays off, but an unreachable list must not strand the page.4. A failure is excused only by proof the submit created the domain. The refetch alone is not enough: the org-scoped list necessarily contains a name the server rejected as a duplicate, because already owning it is why it 409'd. So the handler snapshots the list before the adds and credits only names the list holds more of than it did — counts, not presence, because central-manager accepts a second
PENDING_VALIDATIONrow for a name it already holds — and only for an indeterminate failure — one where the write may still have landed. That is anything but a 4xx refusal: no response, a 408, or any 5xx, since a 500 can be a failure after the insert.AGENTS.md:758states the same rule for the auth forms' non-idempotent POSTs ("only 503 promises a plain retry ... each of those means the request may already have been applied", RFC 9110 §9.2.2). An unloaded snapshot credits nothing.This is the part I got wrong repeatedly under review, so it carries the most tests. Worth naming the last one, because it is the kind of thing that ships: I had 503 as indeterminate and 500 as determinate, which is inverted — a
addDomainthat commits the row and then 500s would have left the domain invisible, with every retry 409ing against an empty-looking table.5.
parseDomainListlower-cases, which changes the POST payload. Previously whatever the user typed was stored; nowExample.comis submitted asexample.com. DNS names are case-insensitive, and the dedup is the reason: central-manager'saddDomainrejects a duplicate only when the existing row isACTIVE, soExample.com, example.comin one submit would create twoPENDING_VALIDATIONrows and two DNS challenges for one identity. Keeping the fold (rather than moving it to comparison-only and reopening that window) was decided deliberately, not left open — flag it if you disagree.6. One Error Tracking event per distinct status, and none for a pure-conflict submit. A 409 is the user naming a domain the org owns and the form now says so inline, so it is never reported — a paste of a dozen owned domains would otherwise bury real signal (#1371, #1386, #1645). I first collapsed this to one event per submit, which four review rounds pushed back on with a better argument each time: a 500 on one domain and a 403 on another is a permissions regression hiding behind a server fault, and only one reached RUM. Per-distinct-status keeps both properties — twelve 409s report nothing, twelve identical 500s report once, a 500 plus a 403 reports both.
7. Two client-side caps: 20 domains, and
20 × 254 + 64characters of raw input. Attempting every entry removed the old loop's accidental circuit breaker, and the field splits on/[,\s]+/, so a pasted log line would have fired one sequential POST per token. The length bound is sized to the domain cap at the 253-character DNS maximum, so a legitimate maximal batch never trips it and the domain cap is what refuses one — each reports its own message. Both are UI guards only; nothing server-side enforces either.8. Failures render via
form.setError('domain', …), which replaces the persistent hint. The "Type in a domain like example.com…" guidance disappears until the next submit. I think a user who just failed wants the reason where the hint was; a dedicated error line would keep both. Same call #1612 made for sign-up.Verification
Unit + component, 47 tests across the two new suites. The component suite mounts the real
DomainsManagement, the realgetOrganizationDomainsQueryOptionsquery and the app's ownmutationErrorHandler— not a restatement of it — which is what proves theskipGlobalErrorToastopt-out and the inline path together (toast.errornever fires on a 409).Every new guard was mutation-checked: reverting it individually turns specific tests red. The ones worth naming, because each was a defect found during review rather than before it — arbitrating all failures instead of only indeterminate ones (3 red), comparing against the post-submit list instead of a snapshot (1 red), treating gateway statuses as determinate (4 red), gating Add on the mutation's
isPending(1 red), dropping the cap (1 red), dropping the dedup (1 red).Two things the tests do not prove, stated plainly:
/Domain/. There is no E2E layer here to defer to.central-manager/src/resources/domain/addDomain.jsthrowsnew ClientError('Domain already exists', ERROR_CODE.CONFLICT)(409), andharper/server/REST.tsserializes it as RFC 9457 withcode = error.code ?? error.constructor.name— socodeis the class nameClientError, not the status phrase, andtitlecarries the sentence. The fixtures use that exact body. A reviewer cannot re-run this from this repo, and it matters: if a duplicate ever answered 400 instead, the!== 409filter wouldconsole.errorevery expected conflict and recreate the noise this PR reduces.Review coverage
Ten pre-push rounds. Findings went
major→minor→nit; the last full round's Harper lens traced the reconciliation through every edge (unloaded list, failed refetch, already-owned 409, concurrent creation) and found it clean.Coverage was degraded throughout and should not be read as ten rounds of breadth:
exit-1. Only rounds 4 and 7 have graded coverage.src/features/auth/*— files this branch does not touch — so the adjudicator dropped all of their findings in three separate rounds. Theirokstatus is not evidence they looked at this diff.Rejected on evidence rather than silently: gemini's
describeError-may-throw (errorTextguards every branch includingJSON.stringify), and itsmajorthat the hook-levelskipGlobalErrorToastbreaks other consumers — the hook has exactly one consumer, anduseSignUp,useForgotPasswordanduseCloudSignInall hardcode the same meta, so it is the established shape here.Filed separately, not fixed here
useSignUpreports every rejection to Error Tracking;POST /User/409'd 47× in 30 days, all becoming RUM errors for text [RUM] Half of sign-up submissions rejected 409 with no inline feedback — 7 of 8 affected sessions never created an account #1612 made inline copy. This PR sets the opposite norm on a sibling form. Comment on it also recordsuseForgotPasswordhaving the inverse bug (no mutation-levelonError, so a 503 during unmount reports nothing) and measures the CAPTCHA-noise concern raised alongside it at 0 events in 30 days.vitestexits 1 with all tests passing, ~1 run in 3: undici's WebSocket dispatches a NodeEventinto the jsdom realm after teardown.source:networkerrors at all, soshouldKeepEvent's URL-based gates are dead code. Relevant here because two reviewers and an adjudicator assumed on an earlier PR that a failed instance request reaches Error Tracking as a resource error; it does not, andconsole.erroris the only route.form.reset()before the refetch, mine reordered them), so it is fixed here instead. The comment explains the misclassification.Closes #1685
Complexity: complicated
Review-Coverage: authored=claude; ran=gemini,codex,cursor-grok; adjudicated=domain; declined=cursor-composer; rounds=19 @ 0765c0e
Human-Review-Need: 3 (decisions: count-based-crediting-vs-server-idempotency, bounded-4s-reconcile-vs-awaiting-refetch, unlock-form-when-list-load-fails, networkmode-always-on-this-mutation, max-20-per-submit-cap) @ 0765c0e