Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/helpers/webhooks_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,16 @@ def webhook_status_badge(webhook)
tag.span(t('developers.webhooks.index.not_validated'), class: 'fr-badge fr-badge--error fr-badge--sm')
end
end

def webhook_attempt_result_badge(webhook_attempt, size:)
result, status = if webhook_attempt.abandoned?
%i[abandoned warning]
elsif webhook_attempt.success?
%i[success success]
else
%i[failure error]
end

dsfr_badge(status:, size:) { t("developers.webhook_attempts.result_badge.#{result}") }
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Developer::MarkWebhookAttemptAsAbandoned < ApplicationInteractor
def call
return if context.webhook_attempt.blank?

context.webhook_attempt.update!(abandoned_at: Time.current)
end
end
26 changes: 26 additions & 0 deletions app/interactors/developer/notify_webhook_failure.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class Developer::NotifyWebhookFailure < ApplicationInteractor
def call
return unless alert_slot_claimed?

WebhookMailer.with(webhook: webhook).fail.deliver_later
end

private

def alert_slot_claimed?
claimed = false

webhook.with_lock do
next if webhook.failure_alert_throttled?(context.throttle_window)

webhook.update!(last_failure_alert_sent_at: Time.current)
claimed = true
end

claimed
end

def webhook
context.webhook
end
end
70 changes: 47 additions & 23 deletions app/jobs/deliver_authorization_request_webhook_job.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
class DeliverAuthorizationRequestWebhookJob < ApplicationJob
include KeepTrackOfJobAttempts

THRESHOLD_TO_NOTIFY_DATA_PROVIDER = 5
MAX_DELIVERY_ATTEMPTS = 10
EXECUTIONS_BEFORE_NOTIFYING_DATA_PROVIDER = 5
FAILURE_ALERT_THROTTLE_WINDOW = 2.hours

class WebhookDeliveryFailedError < StandardError; end

retry_on(WebhookDeliveryFailedError, wait: :polynomially_longer, attempts: :unlimited)
retry_on(WebhookDeliveryFailedError, wait: :polynomially_longer, attempts: MAX_DELIVERY_ATTEMPTS) do |job, _error|
job.abandon_delivery!
end

def perform(webhook_id, authorization_request_id, event_name, payload) # rubocop:disable Metrics/AbcSize
webhook = Webhook.find(webhook_id)
Expand All @@ -15,26 +17,40 @@ def perform(webhook_id, authorization_request_id, event_name, payload) # rubocop

payload = JSON.parse(payload) if payload.is_a?(String)

result = http_service.call(payload)
result = call_endpoint(http_service, payload)

Developer::SaveWebhookAttempt.call!(
@webhook_attempt = Developer::SaveWebhookAttempt.call!(
webhook: webhook,
authorization_request: authorization_request,
event_name: event_name,
status_code: result[:status_code],
response_body: result[:response_body],
payload: payload
)
).webhook_attempt

if success_http_codes.include?(result[:status_code])
webhook.reset_failure_alert!
handle_success(result[:response_body], authorization_request)
else
handle_error(result, webhook, payload, authorization_request)
end
end

def abandon_delivery!
Developer::MarkWebhookAttemptAsAbandoned.call(webhook_attempt: @webhook_attempt)
track_abandon
rescue StandardError => e
Sentry.capture_exception(e, extras: { webhook_attempt_id: @webhook_attempt&.id })
end

private

def call_endpoint(http_service, payload)
http_service.call(payload)
rescue Faraday::Error => e
{ status_code: nil, response_body: "#{e.class.name}: #{e.message}" }
end

def handle_success(response_body, authorization_request)
json = JSON.parse(response_body)

Expand All @@ -49,7 +65,7 @@ def handle_success(response_body, authorization_request)

def handle_error(result, webhook, payload, authorization_request)
track_error(result, webhook, payload, authorization_request)
notify_webhook_fail(webhook, payload, result) if attempts == THRESHOLD_TO_NOTIFY_DATA_PROVIDER
notify_webhook_fail(webhook) if executions == EXECUTIONS_BEFORE_NOTIFYING_DATA_PROVIDER
webhook_fail!
end

Expand All @@ -58,25 +74,33 @@ def webhook_fail!
end

def track_error(result, webhook, payload, authorization_request)
Sentry.set_extras(
{
webhook_id: webhook.id,
authorization_definition_id: webhook.authorization_definition_id,
authorization_request_id: authorization_request.id,
payload: payload,
tries_count: attempts,
webhook_response_status: result[:status_code],
webhook_response_body: result[:response_body]
}
)
Sentry.set_extras(sentry_extras(result, webhook, payload, authorization_request))

Sentry.capture_message("Fail to call target's api webhook endpoint")
end

def notify_webhook_fail(webhook, _payload, _result)
WebhookMailer.with(
webhook: webhook
).fail.deliver_later
def track_abandon
result = { status_code: @webhook_attempt.status_code, response_body: @webhook_attempt.response_body }

Sentry.set_extras(sentry_extras(result, @webhook_attempt.webhook, @webhook_attempt.payload, @webhook_attempt.authorization_request))

Sentry.capture_message('Webhook delivery abandoned after max attempts', level: :error)
end

def sentry_extras(result, webhook, payload, authorization_request)
{
webhook_id: webhook.id,
authorization_definition_id: webhook.authorization_definition_id,
authorization_request_id: authorization_request.id,
payload: payload,
tries_count: executions,
webhook_response_status: result[:status_code],
webhook_response_body: result[:response_body]
}
end

def notify_webhook_fail(webhook)
Developer::NotifyWebhookFailure.call(webhook: webhook, throttle_window: FAILURE_ALERT_THROTTLE_WINDOW)
end

def success_http_codes
Expand Down
70 changes: 70 additions & 0 deletions app/lib/seeds.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Seeds
PREVIEW_WEBHOOK_URL = 'http://localhost:3000/dummy/failing/webhooks'.freeze

def perform
create_data_providers
create_entities
Expand Down Expand Up @@ -560,6 +562,74 @@ def seeds_for(name)

def create_webhooks
create_api_entreprise_webhooks
create_api_entreprise_preview_webhook
end

def create_api_entreprise_preview_webhook
webhook = Webhook.find_or_initialize_by(
authorization_definition_id: 'api_entreprise',
url: PREVIEW_WEBHOOK_URL
)

webhook.secret = SecureRandom.hex(32) if webhook.secret.blank?
webhook.assign_attributes(
events: %w[submit approve refuse request_changes],
validated: true,
enabled: true
)
webhook.save!

create_preview_webhook_attempts(webhook)

webhook
end

def create_preview_webhook_attempts(webhook)
webhook.attempts.destroy_all

authorization_request = preview_webhook_authorization_request

preview_webhook_attempts_attributes.each do |attributes|
webhook.attempts.create!(
authorization_request:,
payload: preview_webhook_payload(authorization_request, attributes),
**attributes
)
end
end

def preview_webhook_attempts_attributes
[
{ event_name: 'submit', status_code: 200, response_body: '{"status": "ok"}', created_at: 6.hours.ago },
{ event_name: 'approve', status_code: 500, response_body: '{"error": "Internal Server Error"}', created_at: 5.hours.ago },
{ event_name: 'request_changes', status_code: 422, response_body: '{"error": "Unprocessable Content"}', created_at: 4.hours.ago },
{ event_name: 'refuse', status_code: nil, response_body: 'Faraday::ConnectionFailed: Connection refused', created_at: 3.hours.ago },
{ event_name: 'approve', status_code: 500, response_body: '{"error": "Internal Server Error"}', created_at: 2.hours.ago, abandoned_at: 90.minutes.ago },
{ event_name: 'submit', status_code: 422, response_body: '{"error": "Unprocessable Content"}', created_at: 1.hour.ago, abandoned_at: 30.minutes.ago }
]
end

def preview_webhook_payload(authorization_request, attributes)
{
event: attributes[:event_name],
fired_at: attributes[:created_at].to_i,
model_type: authorization_request.type.underscore,
model_id: authorization_request.id,
data: {
id: authorization_request.id,
intitule: authorization_request.intitule,
state: authorization_request.state,
}
}
end

def preview_webhook_authorization_request
@preview_webhook_authorization_request ||=
AuthorizationRequest::APIEntreprise.where(state: 'validated').order(:id).first ||
create_validated_authorization_request(
:api_entreprise,
attributes: { intitule: 'Portail de démonstration des webhooks', applicant: demandeur }
)
end

def create_api_entreprise_webhooks
Expand Down
8 changes: 8 additions & 0 deletions app/models/webhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ def definition
@definition ||= AuthorizationDefinition.find(authorization_definition_id)
end

def failure_alert_throttled?(window)
last_failure_alert_sent_at.present? && last_failure_alert_sent_at.after?(window.ago)
end

def reset_failure_alert!
update!(last_failure_alert_sent_at: nil) if last_failure_alert_sent_at?
end

private

def url_must_be_valid
Expand Down
5 changes: 5 additions & 0 deletions app/models/webhook_attempt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ class WebhookAttempt < ApplicationRecord
scope :recent, -> { order(created_at: :desc) }
scope :failed, -> { where.not(status_code: SUCCESS_STATUS_CODES).or(where(status_code: nil)) }
scope :successful, -> { where(status_code: SUCCESS_STATUS_CODES) }
scope :abandoned, -> { where.not(abandoned_at: nil) }
scope :between_dates, ->(start_date, end_date) { where(created_at: start_date..end_date) }
scope :latest, ->(limit = 100) { recent.limit(limit) }

def success?
SUCCESS_STATUS_CODES.include?(status_code)
end

def abandoned?
abandoned_at.present?
end
end
3 changes: 2 additions & 1 deletion app/serializers/api/v1/webhook_attempt_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ class API::V1::WebhookAttemptSerializer < ActiveModel::Serializer
:status_code,
:response_body,
:created_at,
:authorization_request_id
:authorization_request_id,
:abandoned_at

def event
object.event_name
Expand Down
5 changes: 5 additions & 0 deletions app/services/webhook_http_service.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
class WebhookHttpService
TIMEOUT = 10
OPEN_TIMEOUT = 5

attr_reader :url, :secret

def initialize(url, secret)
Expand Down Expand Up @@ -33,6 +36,8 @@ def calculate_signature(payload_json)
def faraday_client
Faraday.new(url: url) do |faraday|
faraday.adapter Faraday.default_adapter
faraday.options.timeout = TIMEOUT
faraday.options.open_timeout = OPEN_TIMEOUT
end
end
end
6 changes: 1 addition & 5 deletions app/views/developers/webhook_attempts/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,7 @@
</td>
<td><%= l(webhook_attempt.created_at, format: :long) %></td>
<td>
<% if webhook_attempt.success? %>
<span class="fr-badge fr-badge--success fr-badge--sm"><%= t('.success') %></span>
<% else %>
<span class="fr-badge fr-badge--error fr-badge--sm"><%= t('.failure') %></span>
<% end %>
<%= webhook_attempt_result_badge(webhook_attempt, size: :sm) %>
</td>
<td>
<%= link_to t('.view_details'), developers_webhook_webhook_attempt_path(@webhook, webhook_attempt), class: %w[fr-btn fr-btn--sm fr-btn--secondary] %>
Expand Down
12 changes: 4 additions & 8 deletions app/views/developers/webhook_attempts/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<div class="sub-header">
<div>
<h1 class="fr-m-0"><%= t('.title') %></h1>
<h1 class="fr-mt-3w"><%= t('.title') %></h1>
</div>
<div>
<%= button_to t('.replay'), replay_developers_webhook_webhook_attempt_path(@webhook, @webhook_attempt), method: :post, class: %w[fr-btn] %>
Expand Down Expand Up @@ -72,13 +72,9 @@
<div class="fr-card__body">
<div class="fr-card__content">
<p class="fr-card__title"><%= t('.result') %></p>
<p class="fr-card__desc">
<% if @webhook_attempt.success? %>
<span class="fr-badge fr-badge--success"><%= t('.success') %></span>
<% else %>
<span class="fr-badge fr-badge--error"><%= t('.failure') %></span>
<% end %>
</p>
<div class="fr-card__desc">
<%= webhook_attempt_result_badge(@webhook_attempt, size: :md) %>
</div>
</div>
</div>
</div>
Expand Down
8 changes: 4 additions & 4 deletions config/locales/developers.fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ fr:
<p>Les webhooks vous permettent de recevoir des notifications en temps réel lorsque des événements se produisent sur vos demandes d'autorisation.</p>
<p>Pour tester votre webhook, vous pouvez utiliser <a href="https://webhook.site" target="_blank" rel="noopener noreferrer" class="fr-link">webhook.site</a>.</p>
webhook_attempts:
result_badge:
success: Succès
failure: Échec
abandoned: Abandonné
index:
title: Historique des appels
back: Retour aux webhooks
Expand All @@ -116,8 +120,6 @@ fr:
date: Date
result: Résultat
actions: Actions
success: ✓ Succès
failure: ✗ Échec
view_details: Voir détails
no_response: Pas de réponse
show:
Expand All @@ -128,8 +130,6 @@ fr:
event: Événement
date: Date
result: Résultat
success: Succès
failure: Échec
no_response: Pas de réponse
payload: Payload envoyé
response: Réponse reçue
Expand Down
5 changes: 5 additions & 0 deletions config/openapi/v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,11 @@ components:
type: integer
example: 456
description: Identifiant de la demande d'habilitation concernée par l'appel webhook
abandoned_at:
type: string
format: date-time
description: Date et heure à laquelle la livraison a été définitivement abandonnée après épuisement des tentatives de relivraison. Absent tant que la livraison n'est pas abandonnée.
nullable: true
required:
- id
- event
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddAbandonedAtToWebhookAttempts < ActiveRecord::Migration[8.1]
def change
add_column :webhook_attempts, :abandoned_at, :datetime
end
end
Loading