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 {
ensure_email_passes_gate(email)
ensure_email_passes_gate(&redis, email)
.await
.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.
async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> {
let action = check_email_gate(email)
async fn ensure_email_passes_gate(
redis: &RedisPool,
email: &str,
) -> Result<(), ApiError> {
let action = check_email_gate(redis, email)
.await
.wrap_request_err("checking email address")?;
@@ -1914,10 +1917,13 @@ async fn ensure_email_passes_gate(email: &str) -> Result<(), ApiError> {
Ok(())
}
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
ensure_email_passes_gate(email).await?;
async fn ensure_email_is_usable(
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
.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
.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
.wrap_api_err("validating email is usable")?;
+39 -1
View File
@@ -6,11 +6,22 @@ use neverbounce::{
VerificationResult,
};
use tracing::{debug, error};
use xredis::RedisPool;
use crate::env::ENV;
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() {
debug!(
result = "unknown",
@@ -19,6 +30,26 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
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)
.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(),
"NeverBounce email check succeeded",
);
redis
.connect()
.await?
.set(&cache_key, result.as_str(), Some(CACHE_EXPIRY_SECONDS))
.await?;
Ok(result)
}
failure_type => {
+46 -7
View File
@@ -5,12 +5,16 @@ use reqwest::StatusCode;
use serde::{Deserialize, Deserializer, Serialize};
use tracing::{debug, error, warn};
use xredis::RedisPool;
use crate::env::ENV;
use crate::util::http::HTTP_CLIENT;
use crate::util::neverbounce::email_check_error_generic;
pub const DEFAULT_API_URL: &str = "https://api.usercheck.com";
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)]
pub enum DecisionAction {
@@ -21,6 +25,15 @@ pub enum 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 {
match self {
DecisionAction::Allow => "allow",
@@ -36,12 +49,7 @@ impl<'de> Deserialize<'de> for DecisionAction {
where
D: Deserializer<'de>,
{
Ok(match String::deserialize(deserializer)?.as_str() {
"allow" => Self::Allow,
"block" => Self::Block,
"challenge" => Self::Challenge,
value => Self::Unrecognized(value.to_owned()),
})
Ok(Self::from_api_value(&String::deserialize(deserializer)?))
}
}
@@ -85,7 +93,12 @@ struct DecisionRequest<'a> {
/// signup, while anything else is an error that rejects the signup.
/// `Challenge` resolves to `Allow` because these flows have no step-up
/// 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() {
debug!(
action = "allow",
@@ -94,6 +107,26 @@ pub async fn check_email_gate(email: &str) -> eyre::Result<DecisionAction> {
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 response = request_decision(email).await;
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)
}