feat(webhooks): allow manually retrying delivery of outgoing webhooks (#4176)

This commit is contained in:
Sanchith Hegde
2024-04-04 14:37:51 +05:30
committed by GitHub
parent 21e2d78117
commit 63d2b6855a
16 changed files with 449 additions and 64 deletions

View File

@ -34,6 +34,7 @@ pub enum Permission {
WebhookEventRead, WebhookEventRead,
PayoutWrite, PayoutWrite,
PayoutRead, PayoutRead,
WebhookEventWrite,
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]

View File

@ -94,6 +94,14 @@ pub struct EventRetrieveResponse {
pub delivery_attempt: Option<WebhookDeliveryAttempt>, pub delivery_attempt: Option<WebhookDeliveryAttempt>,
} }
impl common_utils::events::ApiEventMetric for EventRetrieveResponse {
fn get_api_event_type(&self) -> Option<common_utils::events::ApiEventsType> {
Some(common_utils::events::ApiEventsType::Events {
merchant_id_or_profile_id: self.event_information.merchant_id.clone(),
})
}
}
/// The request information (headers and body) sent in the webhook. /// The request information (headers and body) sent in the webhook.
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct OutgoingWebhookRequestContent { pub struct OutgoingWebhookRequestContent {
@ -114,20 +122,24 @@ pub struct OutgoingWebhookRequestContent {
#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] #[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct OutgoingWebhookResponseContent { pub struct OutgoingWebhookResponseContent {
/// The response body received for the webhook sent. /// The response body received for the webhook sent.
#[schema(value_type = String)] #[schema(value_type = Option<String>)]
#[serde(alias = "payload")] #[serde(alias = "payload")]
pub body: Secret<String>, pub body: Option<Secret<String>>,
/// The response headers received for the webhook sent. /// The response headers received for the webhook sent.
#[schema( #[schema(
value_type = Vec<(String, String)>, value_type = Option<Vec<(String, String)>>,
example = json!([["content-type", "application/json"], ["content-length", "1024"]])) example = json!([["content-type", "application/json"], ["content-length", "1024"]]))
] ]
pub headers: Vec<(String, Secret<String>)>, pub headers: Option<Vec<(String, Secret<String>)>>,
/// The HTTP status code for the webhook sent. /// The HTTP status code for the webhook sent.
#[schema(example = 200)] #[schema(example = 200)]
pub status_code: u16, pub status_code: Option<u16>,
/// Error message in case any error occurred when trying to deliver the webhook.
#[schema(example = 200)]
pub error_message: Option<String>,
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@ -157,3 +169,17 @@ impl common_utils::events::ApiEventMetric for WebhookDeliveryAttemptListRequestI
}) })
} }
} }
#[derive(Debug, serde::Serialize)]
pub struct WebhookDeliveryRetryRequestInternal {
pub merchant_id_or_profile_id: String,
pub event_id: String,
}
impl common_utils::events::ApiEventMetric for WebhookDeliveryRetryRequestInternal {
fn get_api_event_type(&self) -> Option<common_utils::events::ApiEventsType> {
Some(common_utils::events::ApiEventsType::Events {
merchant_id_or_profile_id: self.merchant_id_or_profile_id.clone(),
})
}
}

View File

@ -177,6 +177,7 @@ Never share your secret api keys. Keep them guarded and secure.
// Routes for events // Routes for events
routes::webhook_events::list_initial_webhook_delivery_attempts, routes::webhook_events::list_initial_webhook_delivery_attempts,
routes::webhook_events::list_webhook_delivery_attempts, routes::webhook_events::list_webhook_delivery_attempts,
routes::webhook_events::retry_webhook_delivery_attempt,
), ),
components(schemas( components(schemas(
api_models::refunds::RefundRequest, api_models::refunds::RefundRequest,

View File

@ -68,3 +68,27 @@ pub fn list_initial_webhook_delivery_attempts() {}
security(("admin_api_key" = [])) security(("admin_api_key" = []))
)] )]
pub fn list_webhook_delivery_attempts() {} pub fn list_webhook_delivery_attempts() {}
/// Events - Manual Retry
///
/// Manually retry the delivery of the specified Event.
#[utoipa::path(
post,
path = "/events/{merchant_id_or_profile_id}/{event_id}/retry",
params(
("merchant_id_or_profile_id" = String, Path, description = "The unique identifier for the Merchant Account or Business Profile"),
("event_id" = String, Path, description = "The unique identifier for the Event"),
),
responses(
(
status = 200,
description = "The delivery of the Event was attempted. \
Check the `response` field in the response payload to identify the status of the delivery attempt.",
body = EventRetrieveResponse
),
),
tag = "Event",
operation_id = "Manually retry the delivery of an Event",
security(("admin_api_key" = []))
)]
pub fn retry_webhook_delivery_attempt() {}

View File

@ -887,9 +887,69 @@ async fn trigger_webhook_to_merchant(
); );
logger::debug!(outgoing_webhook_response=?response); logger::debug!(outgoing_webhook_response=?response);
let update_event_if_client_error =
|state: AppState,
merchant_key_store: domain::MerchantKeyStore,
merchant_id: String,
event_id: String,
error_message: String| async move {
let is_webhook_notified = false;
let response_to_store = OutgoingWebhookResponseContent {
body: None,
headers: None,
status_code: None,
error_message: Some(error_message),
};
let event_update = domain::EventUpdate::UpdateResponse {
is_webhook_notified,
response: Some(
domain_types::encrypt(
response_to_store
.encode_to_string_of_json()
.change_context(
errors::WebhooksFlowError::OutgoingWebhookResponseEncodingFailed,
)
.map(Secret::new)?,
merchant_key_store.key.get_inner().peek(),
)
.await
.change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed)
.attach_printable("Failed to encrypt outgoing webhook response content")?,
),
};
state
.store
.update_event_by_merchant_id_event_id(
&merchant_id,
&event_id,
event_update,
&merchant_key_store,
)
.await
.change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed)
};
let api_client_error_handler = let api_client_error_handler =
|client_error: error_stack::Report<errors::ApiClientError>, |state: AppState,
delivery_attempt: enums::WebhookDeliveryAttempt| { merchant_key_store: domain::MerchantKeyStore,
merchant_id: String,
event_id: String,
client_error: error_stack::Report<errors::ApiClientError>,
delivery_attempt: enums::WebhookDeliveryAttempt| async move {
// Not including detailed error message in response information since it contains too
// much of diagnostic information to be exposed to the merchant.
update_event_if_client_error(
state,
merchant_key_store,
merchant_id,
event_id,
"Unable to send request to merchant server".to_string(),
)
.await?;
let error = let error =
client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed); client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed);
logger::error!( logger::error!(
@ -897,6 +957,8 @@ async fn trigger_webhook_to_merchant(
?delivery_attempt, ?delivery_attempt,
"An error occurred when sending webhook to merchant" "An error occurred when sending webhook to merchant"
); );
Ok::<_, error_stack::Report<errors::WebhooksFlowError>>(())
}; };
let update_event_in_storage = |state: AppState, let update_event_in_storage = |state: AppState,
merchant_key_store: domain::MerchantKeyStore, merchant_key_store: domain::MerchantKeyStore,
@ -934,9 +996,10 @@ async fn trigger_webhook_to_merchant(
Secret::from(String::from("Non-UTF-8 response body")) Secret::from(String::from("Non-UTF-8 response body"))
}); });
let response_to_store = OutgoingWebhookResponseContent { let response_to_store = OutgoingWebhookResponseContent {
body: response_body, body: Some(response_body),
headers: response_headers, headers: Some(response_headers),
status_code: status_code.as_u16(), status_code: Some(status_code.as_u16()),
error_message: None,
}; };
let event_update = domain::EventUpdate::UpdateResponse { let event_update = domain::EventUpdate::UpdateResponse {
@ -953,7 +1016,7 @@ async fn trigger_webhook_to_merchant(
) )
.await .await
.change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed)
.attach_printable("Failed to encrypt outgoing webhook request content")?, .attach_printable("Failed to encrypt outgoing webhook response content")?,
), ),
}; };
state state
@ -967,16 +1030,19 @@ async fn trigger_webhook_to_merchant(
.await .await
.change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed)
}; };
let increment_webhook_outgoing_received_count = |merchant_id: String| {
metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add(
&metrics::CONTEXT,
1,
&[metrics::KeyValue::new(MERCHANT_ID, merchant_id)],
)
};
let success_response_handler = let success_response_handler =
|state: AppState, |state: AppState,
merchant_id: String, merchant_id: String,
process_tracker: Option<storage::ProcessTracker>, process_tracker: Option<storage::ProcessTracker>,
business_status: &'static str| async move { business_status: &'static str| async move {
metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( increment_webhook_outgoing_received_count(merchant_id);
&metrics::CONTEXT,
1,
&[metrics::KeyValue::new(MERCHANT_ID, merchant_id)],
);
match process_tracker { match process_tracker {
Some(process_tracker) => state Some(process_tracker) => state
@ -1006,7 +1072,17 @@ async fn trigger_webhook_to_merchant(
match delivery_attempt { match delivery_attempt {
enums::WebhookDeliveryAttempt::InitialAttempt => match response { enums::WebhookDeliveryAttempt::InitialAttempt => match response {
Err(client_error) => api_client_error_handler(client_error, delivery_attempt), Err(client_error) => {
api_client_error_handler(
state.clone(),
merchant_key_store.clone(),
business_profile.merchant_id.clone(),
event_id.clone(),
client_error,
delivery_attempt,
)
.await?
}
Ok(response) => { Ok(response) => {
let status_code = response.status(); let status_code = response.status();
let _updated_event = update_event_in_storage( let _updated_event = update_event_in_storage(
@ -1043,7 +1119,15 @@ async fn trigger_webhook_to_merchant(
.attach_printable("`process_tracker` is unavailable in automatic retry flow")?; .attach_printable("`process_tracker` is unavailable in automatic retry flow")?;
match response { match response {
Err(client_error) => { Err(client_error) => {
api_client_error_handler(client_error, delivery_attempt); api_client_error_handler(
state.clone(),
merchant_key_store.clone(),
business_profile.merchant_id.clone(),
event_id.clone(),
client_error,
delivery_attempt,
)
.await?;
// Schedule a retry attempt for webhook delivery // Schedule a retry attempt for webhook delivery
outgoing_webhook_retry::retry_webhook_delivery_task( outgoing_webhook_retry::retry_webhook_delivery_task(
&*state.store, &*state.store,
@ -1095,10 +1179,41 @@ async fn trigger_webhook_to_merchant(
} }
} }
} }
enums::WebhookDeliveryAttempt::ManualRetry => { enums::WebhookDeliveryAttempt::ManualRetry => match response {
// Will be updated when manual retry is implemented Err(client_error) => {
Err(errors::WebhooksFlowError::NotReceivedByMerchant)? api_client_error_handler(
} state.clone(),
merchant_key_store.clone(),
business_profile.merchant_id.clone(),
event_id.clone(),
client_error,
delivery_attempt,
)
.await?
}
Ok(response) => {
let status_code = response.status();
let _updated_event = update_event_in_storage(
state.clone(),
merchant_key_store.clone(),
business_profile.merchant_id.clone(),
event_id.clone(),
response,
)
.await?;
if status_code.is_success() {
increment_webhook_outgoing_received_count(business_profile.merchant_id.clone());
} else {
error_response_handler(
business_profile.merchant_id,
delivery_attempt,
status_code.as_u16(),
"Ignoring error when sending webhook to merchant",
);
}
}
},
} }
Ok(()) Ok(())

View File

@ -1,19 +1,21 @@
use error_stack::ResultExt; use error_stack::ResultExt;
use masking::PeekInterface;
use router_env::{instrument, tracing}; use router_env::{instrument, tracing};
use crate::{ use crate::{
core::errors::{self, RouterResponse, StorageErrorExt}, core::errors::{self, RouterResponse, StorageErrorExt},
routes::AppState, routes::AppState,
services::ApplicationResponse, services::ApplicationResponse,
types::{api, domain, transformers::ForeignTryFrom}, types::{api, domain, storage, transformers::ForeignTryFrom},
utils::{OptionExt, StringExt},
}; };
const INITIAL_DELIVERY_ATTEMPTS_LIST_MAX_LIMIT: i64 = 100; const INITIAL_DELIVERY_ATTEMPTS_LIST_MAX_LIMIT: i64 = 100;
#[derive(Debug)] #[derive(Debug)]
enum MerchantIdOrProfileId { enum MerchantAccountOrBusinessProfile {
MerchantId(String), MerchantAccount(domain::MerchantAccount),
ProfileId(String), BusinessProfile(storage::BusinessProfile),
} }
#[instrument(skip(state))] #[instrument(skip(state))]
@ -27,22 +29,22 @@ pub async fn list_initial_delivery_attempts(
let store = state.store.as_ref(); let store = state.store.as_ref();
let (identifier, key_store) = let (account, key_store) =
determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?;
let events = match constraints { let events = match constraints {
api_models::webhook_events::EventListConstraintsInternal::ObjectIdFilter { object_id } => { api_models::webhook_events::EventListConstraintsInternal::ObjectIdFilter { object_id } => {
match identifier { match account {
MerchantIdOrProfileId::MerchantId(merchant_id) => store MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store
.list_initial_events_by_merchant_id_primary_object_id( .list_initial_events_by_merchant_id_primary_object_id(
&merchant_id, &merchant_account.merchant_id,
&object_id, &object_id,
&key_store, &key_store,
) )
.await, .await,
MerchantIdOrProfileId::ProfileId(profile_id) => store MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store
.list_initial_events_by_profile_id_primary_object_id( .list_initial_events_by_profile_id_primary_object_id(
&profile_id, &business_profile.profile_id,
&object_id, &object_id,
&key_store, &key_store,
) )
@ -69,10 +71,10 @@ pub async fn list_initial_delivery_attempts(
_ => None, _ => None,
}; };
match identifier { match account {
MerchantIdOrProfileId::MerchantId(merchant_id) => store MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store
.list_initial_events_by_merchant_id_constraints( .list_initial_events_by_merchant_id_constraints(
&merchant_id, &merchant_account.merchant_id,
created_after, created_after,
created_before, created_before,
limit, limit,
@ -80,9 +82,9 @@ pub async fn list_initial_delivery_attempts(
&key_store, &key_store,
) )
.await, .await,
MerchantIdOrProfileId::ProfileId(profile_id) => store MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store
.list_initial_events_by_profile_id_constraints( .list_initial_events_by_profile_id_constraints(
&profile_id, &business_profile.profile_id,
created_after, created_after,
created_before, created_before,
limit, limit,
@ -112,23 +114,23 @@ pub async fn list_delivery_attempts(
) -> RouterResponse<Vec<api::webhook_events::EventRetrieveResponse>> { ) -> RouterResponse<Vec<api::webhook_events::EventRetrieveResponse>> {
let store = state.store.as_ref(); let store = state.store.as_ref();
let (identifier, key_store) = let (account, key_store) =
determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?;
let events = match identifier { let events = match account {
MerchantIdOrProfileId::MerchantId(merchant_id) => { MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => {
store store
.list_events_by_merchant_id_initial_attempt_id( .list_events_by_merchant_id_initial_attempt_id(
&merchant_id, &merchant_account.merchant_id,
&initial_attempt_id, &initial_attempt_id,
&key_store, &key_store,
) )
.await .await
} }
MerchantIdOrProfileId::ProfileId(profile_id) => { MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => {
store store
.list_events_by_profile_id_initial_attempt_id( .list_events_by_profile_id_initial_attempt_id(
&profile_id, &business_profile.profile_id,
&initial_attempt_id, &initial_attempt_id,
&key_store, &key_store,
) )
@ -153,10 +155,108 @@ pub async fn list_delivery_attempts(
} }
} }
#[instrument(skip(state))]
pub async fn retry_delivery_attempt(
state: AppState,
merchant_id_or_profile_id: String,
event_id: String,
) -> RouterResponse<api::webhook_events::EventRetrieveResponse> {
let store = state.store.as_ref();
let (account, key_store) =
determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?;
let event_to_retry = store
.find_event_by_merchant_id_event_id(&key_store.merchant_id, &event_id, &key_store)
.await
.to_not_found_response(errors::ApiErrorResponse::EventNotFound)?;
let business_profile = match account {
MerchantAccountOrBusinessProfile::MerchantAccount(_) => {
let business_profile_id = event_to_retry
.business_profile_id
.get_required_value("business_profile_id")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to read business profile ID from event to retry")?;
store
.find_business_profile_by_profile_id(&business_profile_id)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to find business profile")
}
MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => Ok(business_profile),
}?;
let delivery_attempt = storage::enums::WebhookDeliveryAttempt::ManualRetry;
let new_event_id = super::utils::generate_event_id();
let idempotent_event_id = super::utils::get_idempotent_event_id(
&event_to_retry.primary_object_id,
event_to_retry.event_type,
delivery_attempt,
);
let now = common_utils::date_time::now();
let new_event = domain::Event {
event_id: new_event_id.clone(),
event_type: event_to_retry.event_type,
event_class: event_to_retry.event_class,
is_webhook_notified: false,
primary_object_id: event_to_retry.primary_object_id,
primary_object_type: event_to_retry.primary_object_type,
created_at: now,
merchant_id: Some(business_profile.merchant_id.clone()),
business_profile_id: Some(business_profile.profile_id.clone()),
primary_object_created_at: event_to_retry.primary_object_created_at,
idempotent_event_id: Some(idempotent_event_id),
initial_attempt_id: event_to_retry.initial_attempt_id,
request: event_to_retry.request,
response: None,
delivery_attempt: Some(delivery_attempt),
};
let event = store
.insert_event(new_event, &key_store)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to insert event")?;
// We only allow retrying deliveries for events with `request` populated.
let request_content = event
.request
.as_ref()
.get_required_value("request")
.change_context(errors::ApiErrorResponse::InternalServerError)?
.peek()
.parse_struct("OutgoingWebhookRequestContent")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to parse webhook event request information")?;
super::trigger_webhook_and_raise_event(
state.clone(),
business_profile,
&key_store,
event,
request_content,
delivery_attempt,
None,
None,
)
.await;
let updated_event = store
.find_event_by_merchant_id_event_id(&key_store.merchant_id, &new_event_id, &key_store)
.await
.to_not_found_response(errors::ApiErrorResponse::EventNotFound)?;
Ok(ApplicationResponse::Json(
api::webhook_events::EventRetrieveResponse::try_from(updated_event)?,
))
}
async fn determine_identifier_and_get_key_store( async fn determine_identifier_and_get_key_store(
state: AppState, state: AppState,
merchant_id_or_profile_id: String, merchant_id_or_profile_id: String,
) -> errors::RouterResult<(MerchantIdOrProfileId, domain::MerchantKeyStore)> { ) -> errors::RouterResult<(MerchantAccountOrBusinessProfile, domain::MerchantKeyStore)> {
let store = state.store.as_ref(); let store = state.store.as_ref();
match store match store
.get_merchant_key_store_by_merchant_id( .get_merchant_key_store_by_merchant_id(
@ -165,13 +265,25 @@ async fn determine_identifier_and_get_key_store(
) )
.await .await
{ {
// Valid merchant ID // Since a merchant key store was found with `merchant_id` = `merchant_id_or_profile_id`,
Ok(key_store) => Ok(( // `merchant_id_or_profile_id` is a valid merchant ID.
MerchantIdOrProfileId::MerchantId(merchant_id_or_profile_id), // Find a merchant account having `merchant_id` = `merchant_id_or_profile_id`.
key_store, Ok(key_store) => {
)), let merchant_account = store
.find_merchant_account_by_merchant_id(&merchant_id_or_profile_id, &key_store)
.await
.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?;
// Invalid merchant ID, check if we can find a business profile with the identifier Ok((
MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account),
key_store,
))
}
// Since no merchant key store was found with `merchant_id` = `merchant_id_or_profile_id`,
// `merchant_id_or_profile_id` is not a valid merchant ID.
// Assuming that `merchant_id_or_profile_id` is a business profile ID, try to find a
// business profile having `profile_id` = `merchant_id_or_profile_id`.
Err(error) if error.current_context().is_db_not_found() => { Err(error) if error.current_context().is_db_not_found() => {
router_env::logger::debug!( router_env::logger::debug!(
?error, ?error,
@ -195,7 +307,7 @@ async fn determine_identifier_and_get_key_store(
.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?;
Ok(( Ok((
MerchantIdOrProfileId::ProfileId(business_profile.profile_id), MerchantAccountOrBusinessProfile::BusinessProfile(business_profile),
key_store, key_store,
)) ))
} }

View File

@ -1247,8 +1247,15 @@ impl WebhookEvents {
.app_data(web::Data::new(config)) .app_data(web::Data::new(config))
.service(web::resource("").route(web::get().to(list_initial_webhook_delivery_attempts))) .service(web::resource("").route(web::get().to(list_initial_webhook_delivery_attempts)))
.service( .service(
web::resource("/{event_id}/attempts") web::scope("/{event_id}")
.route(web::get().to(list_webhook_delivery_attempts)), .service(
web::resource("attempts")
.route(web::get().to(list_webhook_delivery_attempts)),
)
.service(
web::resource("retry")
.route(web::post().to(retry_webhook_delivery_attempt)),
),
) )
} }
} }

View File

@ -137,7 +137,8 @@ impl From<Flow> for ApiIdentifier {
Flow::FrmFulfillment Flow::FrmFulfillment
| Flow::IncomingWebhookReceive | Flow::IncomingWebhookReceive
| Flow::WebhookEventInitialDeliveryAttemptList | Flow::WebhookEventInitialDeliveryAttemptList
| Flow::WebhookEventDeliveryAttemptList => Self::Webhooks, | Flow::WebhookEventDeliveryAttemptList
| Flow::WebhookEventDeliveryRetry => Self::Webhooks,
Flow::ApiKeyCreate Flow::ApiKeyCreate
| Flow::ApiKeyRetrieve | Flow::ApiKeyRetrieve

View File

@ -7,6 +7,7 @@ use crate::{
services::{api, authentication as auth, authorization::permissions::Permission}, services::{api, authentication as auth, authorization::permissions::Permission},
types::api::webhook_events::{ types::api::webhook_events::{
EventListConstraints, EventListRequestInternal, WebhookDeliveryAttemptListRequestInternal, EventListConstraints, EventListRequestInternal, WebhookDeliveryAttemptListRequestInternal,
WebhookDeliveryRetryRequestInternal,
}, },
}; };
@ -89,3 +90,42 @@ pub async fn list_webhook_delivery_attempts(
) )
.await .await
} }
#[instrument(skip_all, fields(flow = ?Flow::WebhookEventDeliveryRetry))]
pub async fn retry_webhook_delivery_attempt(
state: web::Data<AppState>,
req: HttpRequest,
path: web::Path<(String, String)>,
) -> impl Responder {
let flow = Flow::WebhookEventDeliveryRetry;
let (merchant_id_or_profile_id, event_id) = path.into_inner();
let request_internal = WebhookDeliveryRetryRequestInternal {
merchant_id_or_profile_id: merchant_id_or_profile_id.clone(),
event_id,
};
api::server_wrap(
flow,
state,
&req,
request_internal,
|state, _, request_internal| {
webhook_events::retry_delivery_attempt(
state,
request_internal.merchant_id_or_profile_id,
request_internal.event_id,
)
},
auth::auth_type(
&auth::AdminApiAuth,
&auth::JWTAuthMerchantOrProfileFromRoute {
merchant_id_or_profile_id,
required_permission: Permission::WebhookEventWrite,
},
req.headers(),
),
api_locking::LockAction::NotApplicable,
)
.await
}

View File

@ -75,12 +75,13 @@ pub static USERS_MANAGE: [Permission; 2] =
pub static MERCHANT_DETAILS_VIEW: [Permission; 1] = [Permission::MerchantAccountRead]; pub static MERCHANT_DETAILS_VIEW: [Permission; 1] = [Permission::MerchantAccountRead];
pub static MERCHANT_DETAILS_MANAGE: [Permission; 5] = [ pub static MERCHANT_DETAILS_MANAGE: [Permission; 6] = [
Permission::MerchantAccountWrite, Permission::MerchantAccountWrite,
Permission::ApiKeyRead, Permission::ApiKeyRead,
Permission::ApiKeyWrite, Permission::ApiKeyWrite,
Permission::MerchantAccountRead, Permission::MerchantAccountRead,
Permission::WebhookEventRead, Permission::WebhookEventRead,
Permission::WebhookEventWrite,
]; ];
pub static ORGANIZATION_MANAGE: [Permission; 2] = [ pub static ORGANIZATION_MANAGE: [Permission; 2] = [

View File

@ -31,6 +31,7 @@ pub enum Permission {
UsersWrite, UsersWrite,
MerchantAccountCreate, MerchantAccountCreate,
WebhookEventRead, WebhookEventRead,
WebhookEventWrite,
PayoutRead, PayoutRead,
PayoutWrite, PayoutWrite,
} }
@ -71,6 +72,7 @@ impl Permission {
Self::UsersWrite => "Invite users, assign and update roles", Self::UsersWrite => "Invite users, assign and update roles",
Self::MerchantAccountCreate => "Create merchant account", Self::MerchantAccountCreate => "Create merchant account",
Self::WebhookEventRead => "View webhook events", Self::WebhookEventRead => "View webhook events",
Self::WebhookEventWrite => "Trigger retries for webhook events",
Self::PayoutRead => "View all payouts", Self::PayoutRead => "View all payouts",
Self::PayoutWrite => "Create payout, download payout data", Self::PayoutWrite => "Create payout, download payout data",
} }

View File

@ -2,4 +2,5 @@ pub use api_models::webhook_events::{
EventListConstraints, EventListConstraintsInternal, EventListItemResponse, EventListConstraints, EventListConstraintsInternal, EventListItemResponse,
EventListRequestInternal, EventRetrieveResponse, OutgoingWebhookRequestContent, EventListRequestInternal, EventRetrieveResponse, OutgoingWebhookRequestContent,
OutgoingWebhookResponseContent, WebhookDeliveryAttemptListRequestInternal, OutgoingWebhookResponseContent, WebhookDeliveryAttemptListRequestInternal,
WebhookDeliveryRetryRequestInternal,
}; };

View File

@ -44,6 +44,7 @@ impl From<Permission> for user_role_api::Permission {
Permission::UsersWrite => Self::UsersWrite, Permission::UsersWrite => Self::UsersWrite,
Permission::MerchantAccountCreate => Self::MerchantAccountCreate, Permission::MerchantAccountCreate => Self::MerchantAccountCreate,
Permission::WebhookEventRead => Self::WebhookEventRead, Permission::WebhookEventRead => Self::WebhookEventRead,
Permission::WebhookEventWrite => Self::WebhookEventWrite,
Permission::PayoutRead => Self::PayoutRead, Permission::PayoutRead => Self::PayoutRead,
Permission::PayoutWrite => Self::PayoutWrite, Permission::PayoutWrite => Self::PayoutWrite,
} }

View File

@ -120,7 +120,7 @@ impl ProcessTrackerWorkflow<AppState> for OutgoingWebhookRetryWorkflow {
&key_store, &key_store,
event, event,
request_content, request_content,
storage::enums::WebhookDeliveryAttempt::AutomaticRetry, delivery_attempt,
None, None,
Some(process), Some(process),
) )
@ -172,7 +172,7 @@ impl ProcessTrackerWorkflow<AppState> for OutgoingWebhookRetryWorkflow {
&key_store, &key_store,
event, event,
request_content, request_content,
storage::enums::WebhookDeliveryAttempt::AutomaticRetry, delivery_attempt,
Some(content), Some(content),
Some(process), Some(process),
) )

View File

@ -398,6 +398,8 @@ pub enum Flow {
WebhookEventInitialDeliveryAttemptList, WebhookEventInitialDeliveryAttemptList,
/// List delivery attempts for a webhook event /// List delivery attempts for a webhook event
WebhookEventDeliveryAttemptList, WebhookEventDeliveryAttemptList,
/// Manually retry the delivery for a webhook event
WebhookEventDeliveryRetry,
} }
/// ///

View File

@ -4469,6 +4469,53 @@
} }
] ]
} }
},
"/events/{merchant_id_or_profile_id}/{event_id}/retry": {
"post": {
"tags": [
"Event"
],
"summary": "Events - Manual Retry",
"description": "Events - Manual Retry\n\nManually retry the delivery of the specified Event.",
"operationId": "Manually retry the delivery of an Event",
"parameters": [
{
"name": "merchant_id_or_profile_id",
"in": "path",
"description": "The unique identifier for the Merchant Account or Business Profile",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "event_id",
"in": "path",
"description": "The unique identifier for the Event",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The delivery of the Event was attempted. Check the `response` field in the response payload to identify the status of the delivery attempt.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EventRetrieveResponse"
}
}
}
}
},
"security": [
{
"admin_api_key": []
}
]
}
} }
}, },
"components": { "components": {
@ -11661,15 +11708,11 @@
"OutgoingWebhookResponseContent": { "OutgoingWebhookResponseContent": {
"type": "object", "type": "object",
"description": "The response information (headers, body and status code) received for the webhook sent.", "description": "The response information (headers, body and status code) received for the webhook sent.",
"required": [
"body",
"headers",
"status_code"
],
"properties": { "properties": {
"body": { "body": {
"type": "string", "type": "string",
"description": "The response body received for the webhook sent." "description": "The response body received for the webhook sent.",
"nullable": true
}, },
"headers": { "headers": {
"type": "array", "type": "array",
@ -11696,14 +11739,22 @@
"content-length", "content-length",
"1024" "1024"
] ]
] ],
"nullable": true
}, },
"status_code": { "status_code": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
"description": "The HTTP status code for the webhook sent.", "description": "The HTTP status code for the webhook sent.",
"example": 200, "example": 200,
"nullable": true,
"minimum": 0 "minimum": 0
},
"error_message": {
"type": "string",
"description": "Error message in case any error occurred when trying to deliver the webhook.",
"example": 200,
"nullable": true
} }
} }
}, },