Fail Open vs Fail Closed Email Validation at Signup
by EmailGuard Engineering
Share this article
Enjoyed this article?
More notes on building products, infrastructure, and teams.
by EmailGuard Engineering
More notes on building products, infrastructure, and teams.
Fail open on email validation at signup means you create the account when the checker is unreachable. Fail closed means you reject the signup. Those two policies apply to dependency failure (timeout, 5xx, 429). They do not apply to a successful response that says disposable: true. SMTP vendors often mash timeout, greylist, and catch-all into one unknown status. A classification API does not return catch-all, and you should not copy their 1–2 second timeout onto a call that is usually a cache hit. Why those products are a different job: email validation vs verification.
This guide is the implementation counterpart to the timeout note in how to block disposable emails at signup. You will leave with a failure taxonomy, a timeout that matches EmailGuard's detect API, and a circuit breaker that does not treat a valid classification as an outage.
By the end, you will:
Retry-After.Prerequisites: A server-side signup handler, an API key with the email:detect scope, and a place to store email_check on the user (ok, skipped, recheck). Staging first.
Vendors who SMTP-probe mailboxes return unknown for three different events: the API timed out, the receiving server greylisted the probe, or the domain is catch-all. They then say "fail open on unknown." That sentence hides the policy.
For EmailGuard, those events are not one field:
| What happened | What you have | Typical signup policy |
|---|---|---|
2xx and syntax_validation: false | A bad string | Fail closed. The user can fix it. |
2xx and disposable: true | A throwaway domain | Fail closed on most products. See the disposable signup guide. |
| 2xx and role / relay / public domain flags | Classification, not an outage | Apply role and relay policy. Do not treat as failure. |
| Client timeout or 5xx | No classification | Fail open on free signup. Fail closed on checkout, KYC, or age-gate. |
429 with Retry-After | You are healthy; you are throttled | Do not retry in the request. Fail open or fail closed by the same action table. Honor Retry-After. |
| 401 / 402 / 403 | Auth, plan, or key mistake | Alert ops. Do not show "invalid email." Apply the outage policy. |
| Catch-all / SMTP unknown | Not a detect field | Out of product. Prove the mailbox with a confirmation email if you need existence. |
RFC 9110 defines 503 and Retry-After. RFC 6585 defines 429. Neither RFC says what your signup form should do. That is your product decision.
Integration posts for mailbox verifiers recommend 400 ms (MailCop, 2026), 1–2 seconds (EmailVerify.io), or 1500 ms (BounceZero, 2026-06-08). Those budgets exist because an SMTP handshake often takes 500 ms to 3 s, according to MailCop's architecture guide. Google's RAIL model (updated 2020-06-10) says that at about 1 second the user stops feeling a continuous task. SMTP in the signup request fights that budget.
EmailGuard classifies. It does not open an SMTP session. Most hits are detection_source: precomputed (curated data). When a domain is new, live DNS can run. The resolver timeout defaults to 2000 ms (EMAIL_DNS_TIMEOUT_MS). If your client aborts at 200 ms, you will fail open on the addresses most likely to need live DNS: freshly rotated disposable domains.
Practical client timeouts that work with this API:
email_check=skipped and you recheck in the background.Watch detection_source after you ship. A spike in live_dns with a matching spike in client timeouts means your abort is racing the fallback, not that the API is down.
If live DNS fails inside EmailGuard (resolver error, timeout on our side), the HTTP call still returns 2xx. Disposable stays false and detection_source stays precomputed. That is not your fail-open path. Fail-open is only for your client abort, 5xx, or 429. Treating a 2xx negative as "unknown" copies SMTP vendor behavior we do not implement.
Write the action in one sentence. Signup, checkout, password reset, and KYC are different availability paths.
| Action | Default when the checker is down | Why |
|---|---|---|
| Free or self-serve signup | Fail open, flag, recheck | A downed dependency should not halt registration. Google's SRE availability table allows 8.76 hours/year of unavailability at 99.9%. Fail-closed signup turns that into 8.76 hours of zero new users. |
| Paid upgrade / checkout | Fail closed, or hold the order | A bad receipt email is expensive. MailCop and LeadMagic both fail closed here. |
| KYC / age-gate | Fail closed | Identity is the job. Bulk Email Checker's 2026 Python guide names age-gate as fail-closed. |
| Newsletter capture | Fail open | You can confirm later. |
| Invite accept | Fail open with a tighter recheck | The workspace already exists. |
Document the choice. Support needs to know whether email_check=skipped means "we let them in during an outage" or "we blocked everyone."
Keep the API key on the server. Abort the detect call yourself. MDN documents AbortSignal.timeout() (baseline April 2024) as the straightforward fetch timeout.
const DETECT_MS = 800;
async function detectEmail(email, apiKey) {
const url =
"https://emailguard.co/api/v1/emails/detect?email=" +
encodeURIComponent(email);
let response;
try {
response = await fetch(url, {
headers: { Authorization: "Bearer " + apiKey },
signal: AbortSignal.timeout(DETECT_MS),
});
} catch (err) {
const timeout =
err && (err.name === "TimeoutError" || err.name === "AbortError");
return { kind: timeout ? "timeout" : "network" };
}
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After") || "60");
return { kind: "throttled", retryAfterSeconds: retryAfter };
}
if (response.status >= 500) {
return { kind: "unavailable", httpStatus: response.status };
}
if (response.status === 401 || response.status === 402 || response.status === 403) {
return { kind: "config", httpStatus: response.status };
}
if (!response.ok) {
return { kind: "unavailable", httpStatus: response.status };
}
const body = await response.json();
return { kind: "ok", data: body.data };
}Do not retry 429 or 5xx on the signup thread. Sleeping in the request is how a slow API becomes a hung form. Queue a background job instead.
function decideSignup(result, action) {
if (result.kind === "ok") {
const d = result.data;
if (!d.syntax_validation) {
return { allow: false, reason: "syntax", emailCheck: "ok" };
}
if (d.disposable) {
return { allow: false, reason: "disposable", emailCheck: "ok" };
}
return { allow: true, reason: "classified", emailCheck: "ok", flags: d };
}
const failClosed =
action === "checkout" || action === "kyc" || action === "age_gate";
if (result.kind === "config") {
// Wrong key or unpaid invoice. Not a user typo.
alertOps(result);
}
return {
allow: !failClosed,
reason: result.kind,
emailCheck: failClosed ? "blocked_outage" : "skipped",
};
}When allow is true and emailCheck is skipped, still send a confirmation email if the account can take privileged actions. Confirmation is mailbox proof. Classification is not. That split is the same one in the email validation API checklist.
A timeout on every request still spends the user's 800 ms. After repeated failures, stop calling the API for a cool-down. AWS and Azure both document the circuit breaker as Closed, Open, and Half-Open (Michael Nygard's Release It!). Azure notes that the Open state can return a default value instead of throwing. That default is your fail-open or fail-closed policy.
Count as failures:
Retry-After seconds)Do not count as failures:
disposable: true or syntax_validation: falseIf you count classification 4xx or "disposable true" toward the breaker, one abuse wave opens the circuit and then fail-open lets the next wave through with no checks.
Open-circuit plus fail-open is also a gift if someone tries to exhaust your quota or knock the dependency over. Mitigations that do not require SMTP:
email_check=skippedLog kind, HTTP status, detection_source when you have it, and whether you allowed the signup. Alert when the skipped rate leaves your noise band. MailCop's realtime guide (2026-06-12) uses a vendor heuristic of 1% (tune the client) and 5% (the path is broken). Use those as starting points, not as industry law. Tie the rate to your own error budget.
After the API recovers, re-run detect for email_check=skipped rows. If disposable is now true, lock the account or require a new address. Do not silently leave throwaways from the outage window.
A PLG SaaS uses detect on register and on checkout.
disposable: false. Account created. emailCheck=ok.emailCheck=skipped. Confirmation required before inviting teammates.disposable: true. HTTP 422. No row.Retry-After: 42. No retry in the request. Fail open, breaker stays open 42 seconds, background recheck after.Same API. Two policies. The difference is the action, not a global boolean.
Copying SMTP unknown handling. EmailGuard has no catch-all field. Do not invent one from a timeout.
Aborting at 200 ms and then wondering why live DNS never appears. Raise the client timeout or accept skipped checks on new domains.
Fail-closed on 401. Users see "invalid email." You have a bad key. Page ops.
Retrying 429 inside signup. You add latency and make the rate limit worse. Read Retry-After from the rate limiting guide.
Using fail-open as the disposable policy. Outage handling and throwaway handling are different rows.
Fail open creates the account when the validation API times out or returns 5xx. Fail closed rejects the signup in that situation. A 2xx body with disposable: true is not an outage. Block that even if you fail open on timeouts.
Start at 400–800 ms if you want EmailGuard live DNS to finish. Use 200–300 ms only if you will recheck skipped rows. SMTP guides that say 1–2 seconds are budgeting for a mailbox probe you are not running.
EmailGuard does not return SMTP unknown or catch-all. If your client timed out, that is skipped, not unknown. Confirm the mailbox with email if you need existence proof.
Yes for checkout, KYC, and age-gate. Hold the sensitive action. You can still fail open on the original free signup.
Pass signal: AbortSignal.timeout(800) into fetch. Catch TimeoutError or AbortError and run your outage policy. Do not leave the default HTTP timeout of tens of seconds in place.
No. Google's SRE table converts 99.9% to 8.76 hours of allowed unavailability per year. Fail-closed signup is down for that whole window, plus your own network path to the API.
email_check on the user.detection_source after launch.