EmailGuard
  • Pricing
Log inRegister
Engineer reviewing a signup timeout and error-handling policy on a laptop

Fail Open vs Fail Closed Email Validation at Signup

by EmailGuard Engineering

Unsplash
September 1, 2026engineering11 min read

Share this article

On this page

  • What you'll accomplish
  • Fail open is not "accept unknown addresses"
  • Why SMTP timeout advice is the wrong default here
  • Step 1: Name the product action, not "the app"
  • Step 2: Put the timeout on your client
  • Step 3: Branch on kind, then on flags
  • Step 4: Wrap detect in a circuit breaker
  • Step 5: Measure skipped checks
  • Worked example
  • Common mistakes
  • FAQ
    • What is fail open vs fail closed for email validation at signup?
    • What timeout should I use on a classification API?
    • Should I block the user when verification returns unknown?
    • Should I fail closed for payments?
    • How do I use AbortController with a validation timeout?
    • Does 99.9% uptime make fail-closed signup safe?
  • Next steps

Enjoyed this article?

More notes on building products, infrastructure, and teams.

EmailGuard

Email validation API for signup and lead intake—syntax, disposable, role, relay, and public-domain signals in one request.

Product

  • What's included

Docs

  • Documentation
  • API Reference
  • Knowledge Base

Resources

  • Blog
  • Changelog
  • Free tools
  • Alternatives
  • Legal & security
  • Contact
  • Data correction

Pricing

  • Pricing

© 2026 EmailGuard

A Baker Assets company

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.

What you'll accomplish

By the end, you will:

  1. Separate API failure from address signals.
  2. Set a client timeout that matches classification, not SMTP probing.
  3. Map HTTP outcomes: 2xx flags, 429, 5xx, 4xx, client timeout.
  4. Pick fail open or fail closed per product action.
  5. Add a circuit breaker that ignores classification 4xx and honors Retry-After.
  6. Log skipped checks, queue a recheck, and delay privileges when you fail open.

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.

Fail open is not "accept unknown addresses"

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 happenedWhat you haveTypical signup policy
2xx and syntax_validation: falseA bad stringFail closed. The user can fix it.
2xx and disposable: trueA throwaway domainFail closed on most products. See the disposable signup guide.
2xx and role / relay / public domain flagsClassification, not an outageApply role and relay policy. Do not treat as failure.
Client timeout or 5xxNo classificationFail open on free signup. Fail closed on checkout, KYC, or age-gate.
429 with Retry-AfterYou are healthy; you are throttledDo not retry in the request. Fail open or fail closed by the same action table. Honor Retry-After.
401 / 402 / 403Auth, plan, or key mistakeAlert ops. Do not show "invalid email." Apply the outage policy.
Catch-all / SMTP unknownNot a detect fieldOut 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.

Why SMTP timeout advice is the wrong default here

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:

  • 400–800 ms for signup if you want live DNS to finish on a slow path.
  • 200–300 ms only if you accept more email_check=skipped and you recheck in the background.
  • Do not set 5–30 s. That makes the checker part of signup availability.

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.

Step 1: Name the product action, not "the app"

Write the action in one sentence. Signup, checkout, password reset, and KYC are different availability paths.

ActionDefault when the checker is downWhy
Free or self-serve signupFail open, flag, recheckA 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 / checkoutFail closed, or hold the orderA bad receipt email is expensive. MailCop and LeadMagic both fail closed here.
KYC / age-gateFail closedIdentity is the job. Bulk Email Checker's 2026 Python guide names age-gate as fail-closed.
Newsletter captureFail openYou can confirm later.
Invite acceptFail open with a tighter recheckThe 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."

Step 2: Put the timeout on your client

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.

Step 3: Branch on kind, then on flags

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.

Step 4: Wrap detect in a circuit breaker

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:

  • Client timeouts
  • 5xx
  • Network errors
  • 429 (then stay open at least Retry-After seconds)

Do not count as failures:

  • 2xx with disposable: true or syntax_validation: false
  • 400 on a malformed request you generated (fix the client)

If 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:

  • Local syntax parse before the API (still fail closed on garbage strings)
  • Cache last-good classify by domain for a few minutes
  • Confirmation email before paid features
  • Rate-limit new accounts per IP during email_check=skipped

Step 5: Measure skipped checks

Log 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.

Worked example

A PLG SaaS uses detect on register and on checkout.

  1. Register, 2xx, disposable: false. Account created. emailCheck=ok.
  2. Register, client timeout at 800 ms. Account created. emailCheck=skipped. Confirmation required before inviting teammates.
  3. Register, 2xx, disposable: true. HTTP 422. No row.
  4. Checkout, circuit Open. Payment is held with "email check unavailable, retry shortly." Fail closed on that action only.
  5. Register, 429, 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.

Common mistakes

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.

FAQ

What is fail open vs fail closed for email validation at signup?

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.

What timeout should I use on a classification API?

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.

Should I block the user when verification returns unknown?

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.

Should I fail closed for payments?

Yes for checkout, KYC, and age-gate. Hold the sensitive action. You can still fail open on the original free signup.

How do I use AbortController with a validation timeout?

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.

Does 99.9% uptime make fail-closed signup safe?

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.

Next steps

  1. Encode the action table (signup vs checkout vs KYC) in one module.
  2. Ship the detect call with an explicit timeout and email_check on the user.
  3. Kill network access in staging and confirm the fail-open path. See the test list in the disposable signup guide.
  4. Create a key under pricing plans and watch detection_source after launch.