feat: add redis cache to usercheck/nb responses (#7316)

* feat: add redis cache to usercheck responses

* feat: add redis cache to neverbounce responses
This commit is contained in:
Michael H.
2026-08-26 14:07:15 +02:00
committed by GitHub
parent 36f9dbb47a
commit c3c6760818
4 changed files with 112 additions and 24 deletions
+14 -8
View File
@@ -1544,7 +1544,7 @@ pub async fn create_oauth_account(
}; };
if let Some(email) = &user.email { if let Some(email) = &user.email {
ensure_email_passes_gate(email) ensure_email_passes_gate(&redis, email)
.await .await
.wrap_api_err("validating email passes the signup gate")?; .wrap_api_err("validating email passes the signup gate")?;
} }
@@ -1902,8 +1902,11 @@ impl From<NewAccount> for AccountRegisterFlow {
} }
/// Runs the UserCheck gate, which covers both password and OAuth signups. /// Runs the UserCheck gate, which covers both password and OAuth signups.
async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> { async fn ensure_email_passes_gate(
let action = check_email_gate(email) redis: &RedisPool,
email: &str,
) -> Result<(), ApiError> {
let action = check_email_gate(redis, email)
.await .await
.wrap_request_err("checking email address")?; .wrap_request_err("checking email address")?;
@@ -1914,10 +1917,13 @@ async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> {
Ok(()) Ok(())
} }
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> { async fn ensure_email_is_usable(
ensure_email_passes_gate(email).await?; redis: &RedisPool,
email: &str,
) -> Result<(), ApiError> {
ensure_email_passes_gate(redis, email).await?;
let result = check_email(email) let result = check_email(redis, email)
.await .await
.wrap_request_err("checking email address")?; .wrap_request_err("checking email address")?;
@@ -2144,7 +2150,7 @@ pub async fn create_account_with_password(
))); )));
} }
ensure_email_is_usable(&new_account.email) ensure_email_is_usable(&redis, &new_account.email)
.await .await
.wrap_api_err("validating email is usable")?; .wrap_api_err("validating email is usable")?;
@@ -3129,7 +3135,7 @@ pub async fn set_email(
))); )));
} }
ensure_email_is_usable(&email_address.email) ensure_email_is_usable(&redis, &email_address.email)
.await .await
.wrap_api_err("validating email is usable")?; .wrap_api_err("validating email is usable")?;
+39 -1
View File
@@ -6,11 +6,22 @@ use neverbounce::{
VerificationResult, VerificationResult,
}; };
use tracing::{debug, error}; use tracing::{debug, error};
use xredis::RedisPool;
use crate::env::ENV; use crate::env::ENV;
use crate::util::http::HTTP_CLIENT; use crate::util::http::HTTP_CLIENT;
pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> { const CACHE_NAMESPACE: &str = "neverbounce:v1";
const CACHE_EXPIRY_SECONDS: i64 = 60 * 60;
/// Verdicts are cached in Redis for an hour, keyed by address. Only verdicts
/// NeverBounce actually returned are cached; the `Unknown` we fall back to when
/// the API is unreachable is not, so an outage cannot pin an address for an
/// hour.
pub async fn check_email(
redis: &RedisPool,
email: &str,
) -> eyre::Result<VerificationResult> {
if ENV.NEVERBOUNCE_API_KEY.is_empty() { if ENV.NEVERBOUNCE_API_KEY.is_empty() {
debug!( debug!(
result = "unknown", result = "unknown",
@@ -19,6 +30,26 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
return Ok(VerificationResult::Unknown); return Ok(VerificationResult::Unknown);
} }
let cache_key = {
let mut redis = redis.connect().await?;
let key = redis
.key()
.entity(CACHE_NAMESPACE, email.to_ascii_lowercase());
if let Some(cached) = redis.get(&key).await? {
let result = VerificationResult::from_api_value(&cached);
debug!(
result = result.as_str(),
"NeverBounce email check served from cache",
);
return Ok(result);
}
key
};
let params = SingleCheckParams::new(&ENV.NEVERBOUNCE_API_KEY, email) let params = SingleCheckParams::new(&ENV.NEVERBOUNCE_API_KEY, email)
.with_api_url(&ENV.NEVERBOUNCE_BASE_URL); .with_api_url(&ENV.NEVERBOUNCE_BASE_URL);
@@ -72,6 +103,13 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
request.time_ms = check_time.as_millis(), request.time_ms = check_time.as_millis(),
"NeverBounce email check succeeded", "NeverBounce email check succeeded",
); );
redis
.connect()
.await?
.set(&cache_key, result.as_str(), Some(CACHE_EXPIRY_SECONDS))
.await?;
Ok(result) Ok(result)
} }
failure_type => { failure_type => {
+46 -7
View File
@@ -5,12 +5,16 @@ use reqwest::StatusCode;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use xredis::RedisPool;
use crate::env::ENV; use crate::env::ENV;
use crate::util::http::HTTP_CLIENT; use crate::util::http::HTTP_CLIENT;
use crate::util::neverbounce::email_check_error_generic; use crate::util::neverbounce::email_check_error_generic;
pub const DEFAULT_API_URL: &str = "https://api.usercheck.com"; pub const DEFAULT_API_URL: &str = "https://api.usercheck.com";
const TIMEOUT: Duration = Duration::from_secs(5); const TIMEOUT: Duration = Duration::from_secs(5);
const CACHE_NAMESPACE: &str = "usercheck_gate:v1";
const CACHE_EXPIRY_SECONDS: i64 = 60 * 60;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecisionAction { pub enum DecisionAction {
@@ -21,6 +25,15 @@ pub enum DecisionAction {
} }
impl DecisionAction { impl DecisionAction {
fn from_api_value(value: &str) -> Self {
match value {
"allow" => Self::Allow,
"block" => Self::Block,
"challenge" => Self::Challenge,
value => Self::Unrecognized(value.to_owned()),
}
}
fn as_str(&self) -> &str { fn as_str(&self) -> &str {
match self { match self {
DecisionAction::Allow => "allow", DecisionAction::Allow => "allow",
@@ -36,12 +49,7 @@ impl<'de> Deserialize<'de> for DecisionAction {
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
Ok(match String::deserialize(deserializer)?.as_str() { Ok(Self::from_api_value(&String::deserialize(deserializer)?))
"allow" => Self::Allow,
"block" => Self::Block,
"challenge" => Self::Challenge,
value => Self::Unrecognized(value.to_owned()),
})
} }
} }
@@ -85,7 +93,12 @@ struct DecisionRequest<'a> {
/// signup, while anything else is an error that rejects the signup. /// signup, while anything else is an error that rejects the signup.
/// `Challenge` resolves to `Allow` because these flows have no step-up /// `Challenge` resolves to `Allow` because these flows have no step-up
/// mechanism past the captcha that already ran. /// mechanism past the captcha that already ran.
pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> { ///
/// Verdicts are cached in Redis for an hour, keyed by email address.
pub async fn check_email_gate(
redis: &RedisPool,
email: &str,
) -> eyre::Result<DecisionAction> {
if ENV.USERCHECK_API_KEY.is_empty() || ENV.USERCHECK_GATE_ID.is_empty() { if ENV.USERCHECK_API_KEY.is_empty() || ENV.USERCHECK_GATE_ID.is_empty() {
debug!( debug!(
action = "allow", action = "allow",
@@ -94,6 +107,26 @@ pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> {
return Ok(DecisionAction::Allow); return Ok(DecisionAction::Allow);
} }
let cache_key = {
let mut redis = redis.connect().await?;
let key = redis
.key()
.entity(CACHE_NAMESPACE, email.to_ascii_lowercase());
if let Some(cached) = redis.get(&key).await? {
let action = DecisionAction::from_api_value(&cached);
debug!(
action = action.as_str(),
"UserCheck gate decision served from cache",
);
return Ok(action);
}
key
};
let decision_time_start = Instant::now(); let decision_time_start = Instant::now();
let response = request_decision(email).await; let response = request_decision(email).await;
let decision_time = decision_time_start.elapsed(); let decision_time = decision_time_start.elapsed();
@@ -166,6 +199,12 @@ pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> {
), ),
} }
redis
.connect()
.await?
.set(&cache_key, action.as_str(), Some(CACHE_EXPIRY_SECONDS))
.await?;
Ok(action) Ok(action)
} }
+13 -8
View File
@@ -165,6 +165,18 @@ pub enum VerificationResult {
} }
impl VerificationResult { impl VerificationResult {
#[must_use]
pub fn from_api_value(value: &str) -> Self {
match value {
"valid" => Self::Valid,
"invalid" => Self::Invalid,
"disposable" => Self::Disposable,
"catchall" => Self::CatchAll,
"unknown" => Self::Unknown,
value => Self::Unrecognized(value.to_owned()),
}
}
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
VerificationResult::Valid => "valid", VerificationResult::Valid => "valid",
@@ -182,14 +194,7 @@ impl<'de> Deserialize<'de> for VerificationResult {
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
Ok(match String::deserialize(deserializer)?.as_str() { Ok(Self::from_api_value(&String::deserialize(deserializer)?))
"valid" => Self::Valid,
"invalid" => Self::Invalid,
"disposable" => Self::Disposable,
"catchall" => Self::CatchAll,
"unknown" => Self::Unknown,
value => Self::Unrecognized(value.to_owned()),
})
} }
} }