fix(webhooks): add idempotent_event_id generation using URL-safe Base64 (no padding) and SHA256 digest (#9405)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Ayush Anand
2025-09-22 13:15:52 +05:30
committed by GitHub
parent 740f3af643
commit 0c38bc3d0c
7 changed files with 43 additions and 11 deletions

View File

@ -97,6 +97,11 @@ pub const BASE64_ENGINE: base64::engine::GeneralPurpose = base64::engine::genera
/// URL Safe base64 engine
pub const BASE64_ENGINE_URL_SAFE: base64::engine::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE;
/// URL Safe base64 engine without padding
pub const BASE64_ENGINE_URL_SAFE_NO_PAD: base64::engine::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
/// Regex for matching a domain
/// Eg -
/// http://www.example.com

View File

@ -157,6 +157,8 @@ pub enum WebhooksFlowError {
OutgoingWebhookRetrySchedulingFailed,
#[error("Outgoing webhook response encoding failed")]
OutgoingWebhookResponseEncodingFailed,
#[error("ID generation failed")]
IdGenerationFailed,
}
impl WebhooksFlowError {
@ -174,7 +176,8 @@ impl WebhooksFlowError {
| Self::DisputeWebhookValidationFailed
| Self::OutgoingWebhookEncodingFailed
| Self::OutgoingWebhookProcessTrackerTaskUpdateFailed
| Self::OutgoingWebhookRetrySchedulingFailed => true,
| Self::OutgoingWebhookRetrySchedulingFailed
| Self::IdGenerationFailed => true,
}
}
}

View File

@ -60,7 +60,9 @@ pub(crate) async fn create_event_and_trigger_outgoing_webhook(
) -> CustomResult<(), errors::ApiErrorResponse> {
let delivery_attempt = enums::WebhookDeliveryAttempt::InitialAttempt;
let idempotent_event_id =
utils::get_idempotent_event_id(&primary_object_id, event_type, delivery_attempt);
utils::get_idempotent_event_id(&primary_object_id, event_type, delivery_attempt)
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
.attach_printable("Failed to generate idempotent event ID")?;
let webhook_url_result = get_webhook_url_from_business_profile(&business_profile);
if !state.conf.webhooks.outgoing_enabled

View File

@ -48,7 +48,9 @@ pub(crate) async fn create_event_and_trigger_outgoing_webhook(
) -> CustomResult<(), errors::ApiErrorResponse> {
let delivery_attempt = enums::WebhookDeliveryAttempt::InitialAttempt;
let idempotent_event_id =
utils::get_idempotent_event_id(&primary_object_id, event_type, delivery_attempt);
utils::get_idempotent_event_id(&primary_object_id, event_type, delivery_attempt)
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
.attach_printable("Failed to generate idempotent event ID")?;
let webhook_url_result = business_profile
.get_webhook_url_from_profile()
.change_context(errors::WebhooksFlowError::MerchantWebhookUrlNotConfigured);

View File

@ -1,6 +1,12 @@
use std::marker::PhantomData;
use common_utils::{errors::CustomResult, ext_traits::ValueExt};
use base64::Engine;
use common_utils::{
consts,
crypto::{self, GenerateDigest},
errors::CustomResult,
ext_traits::ValueExt,
};
use error_stack::{Report, ResultExt};
use redis_interface as redis;
use router_env::tracing;
@ -146,18 +152,28 @@ pub(crate) fn get_idempotent_event_id(
primary_object_id: &str,
event_type: types::storage::enums::EventType,
delivery_attempt: types::storage::enums::WebhookDeliveryAttempt,
) -> String {
) -> Result<String, Report<errors::WebhooksFlowError>> {
use crate::types::storage::enums::WebhookDeliveryAttempt;
const EVENT_ID_SUFFIX_LENGTH: usize = 8;
let common_prefix = format!("{primary_object_id}_{event_type}");
match delivery_attempt {
WebhookDeliveryAttempt::InitialAttempt => common_prefix,
// Hash the common prefix with SHA256 and encode with URL-safe base64 without padding
let digest = crypto::Sha256
.generate_digest(common_prefix.as_bytes())
.change_context(errors::WebhooksFlowError::IdGenerationFailed)
.attach_printable("Failed to generate idempotent event ID")?;
let base_encoded = consts::BASE64_ENGINE_URL_SAFE_NO_PAD.encode(digest);
let result = match delivery_attempt {
WebhookDeliveryAttempt::InitialAttempt => base_encoded,
WebhookDeliveryAttempt::AutomaticRetry | WebhookDeliveryAttempt::ManualRetry => {
common_utils::generate_id(EVENT_ID_SUFFIX_LENGTH, &common_prefix)
}
common_utils::generate_id(EVENT_ID_SUFFIX_LENGTH, &base_encoded)
}
};
Ok(result)
}
#[inline]

View File

@ -287,7 +287,9 @@ pub async fn retry_delivery_attempt(
&event_to_retry.primary_object_id,
event_to_retry.event_type,
delivery_attempt,
);
)
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
.attach_printable("Failed to generate idempotent event ID")?;
let now = common_utils::date_time::now();
let new_event = domain::Event {

View File

@ -72,7 +72,9 @@ impl ProcessTrackerWorkflow<SessionState> for OutgoingWebhookRetryWorkflow {
&tracking_data.primary_object_id,
tracking_data.event_type,
delivery_attempt,
);
)
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
.attach_printable("Failed to generate idempotent event ID")?;
let initial_event = match &tracking_data.initial_attempt_id {
Some(initial_attempt_id) => {