diff --git a/.gitignore b/.gitignore index bfb6a0e527..8c3768b3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,11 @@ var/ *.manifest *.spec +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + # Installer logs pip-log.txt pip-delete-this-directory.txt @@ -80,7 +85,6 @@ celerybeat-schedule .cf /scripts/run_my_tests.sh -.vscode # Terraform .terraform @@ -89,6 +93,7 @@ celerybeat-schedule user_flows_exit_code.txt # These files can get written in the notification_api directory if you run bash commands on a container with read-write access. +.ash_history .bash_history .python_history diff --git a/.talismanrc b/.talismanrc index 5592a4b1f7..f74b3fd21f 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,38 +1,42 @@ fileignoreconfig: - - filename: README.md - checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a - - filename: app/celery/contact_information_tasks.py - checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae - - filename: app/notifications/process_notifications.py - checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7 - - filename: app/service/rest.py - checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23 - - filename: app/template/rest.py - checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b - - filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py - checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037 - - filename: lambda_functions/ses_callback/ses_callback_lambda.py - checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b - - filename: poetry.lock - checksum: 29057b41776a4bda33e4cb80fdba023e4a1a2327f9e9ec5eddbabfe26a13dcf3 - - filename: scripts/trigger_task.py - checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778 - - filename: tests/app/celery/test_process_ga4_measurement_task.py - checksum: 6ffb8742a19c5b834c608826fd459cc1b6ea35ebfffd2d929a3a0f269c74183d - - filename: tests/app/conftest.py - checksum: a80aa727586db82ed1b50bdb81ddfe1379e649a9dfc1ece2c36047486b41b83d - - filename: tests/app/notifications/test_process_notifications_for_profile_v3.py - checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065 - - filename: tests/app/v2/notifications/test_post_notifications.py - checksum: 3181930a13e3679bb2f17eaa3f383512eb9caf4ed5d5e14496ca4193c6083965 - - filename: tests/app/callback/test_webhook_callback_strategy.py - checksum: 288841d3209dc3ca885cd0bb08591221f7f15e5b3406fb7140505096db212554 - - filename: app/callback/webhook_callback_strategy.py - checksum: 47846ab651c27512d3ac7864c08cb25d647f63bb84321953f907551fd9d2e85f - - filename: app/dao/api_key_dao.py - checksum: ab93313f306c8a3f6576141e8f32d9fc99b0de7da8d44a1ddbe6ea55d167dcdb - - filename: ci/docker-compose-test.yml - checksum: e3efec2749e8c19e60f5bfc68eafabe24eba647530a482ceccfc4e0e62cff424 - - filename: cd/application-deployment/dev/vaec-api-task-definition.json - checksum: f328ff821339b802eb1d82559e624d5b719857c813d427da5aaa39b240331ddd +- filename: README.md + checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a +- filename: app/callback/webhook_callback_strategy.py + checksum: 47846ab651c27512d3ac7864c08cb25d647f63bb84321953f907551fd9d2e85f +- filename: app/celery/contact_information_tasks.py + checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae +- filename: app/celery/service_callback_tasks.py + checksum: 83b61b21668c1b1a0ea33a4c3130e82c9b14edbd6542079f1dd3d7493f3e9a79 +- filename: app/dao/api_key_dao.py + checksum: ab93313f306c8a3f6576141e8f32d9fc99b0de7da8d44a1ddbe6ea55d167dcdb +- filename: app/notifications/process_notifications.py + checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7 +- filename: app/service/rest.py + checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23 +- filename: app/template/rest.py + checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b +- filename: cd/application-deployment/dev/vaec-api-task-definition.json + checksum: f328ff821339b802eb1d82559e624d5b719857c813d427da5aaa39b240331ddd +- filename: ci/docker-compose-test.yml + checksum: e3efec2749e8c19e60f5bfc68eafabe24eba647530a482ceccfc4e0e62cff424 +- filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py + checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037 +- filename: lambda_functions/ses_callback/ses_callback_lambda.py + checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b +- filename: poetry.lock + checksum: 29057b41776a4bda33e4cb80fdba023e4a1a2327f9e9ec5eddbabfe26a13dcf3 +- filename: scripts/trigger_task.py + checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778 +- filename: tests/app/callback/test_webhook_callback_strategy.py + checksum: 288841d3209dc3ca885cd0bb08591221f7f15e5b3406fb7140505096db212554 +- filename: tests/app/celery/test_process_ga4_measurement_task.py + checksum: 6ffb8742a19c5b834c608826fd459cc1b6ea35ebfffd2d929a3a0f269c74183d +- filename: tests/app/celery/test_service_callback_tasks.py + checksum: 70575434f7a4fedd43d4c9164bc899a606768526d432c364db372524eec26542 +- filename: tests/app/conftest.py + checksum: a80aa727586db82ed1b50bdb81ddfe1379e649a9dfc1ece2c36047486b41b83d +- filename: tests/app/notifications/test_process_notifications_for_profile_v3.py + checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065 +- filename: tests/app/v2/notifications/test_post_notifications.py + checksum: 3181930a13e3679bb2f17eaa3f383512eb9caf4ed5d5e14496ca4193c6083965 version: "1.0" diff --git a/app/callback/README.md b/app/callback/README.md index 3f4f083c74..3bf5d60f64 100644 --- a/app/callback/README.md +++ b/app/callback/README.md @@ -1,4 +1,13 @@ -## Callbacks +# Callbacks + +> [!Note] +> SQS callbacks are not supported at this time, as of 2024. The permissions around setting them up is tricky and makes for a bad experience for our clients. + +## Notification Level Callbacks + +Clients can send a `callback_url` with their notification request. They will then receive callbacks for notification updates to the specified endpoint. + +## Service Level Callbacks Can configure callbacks to be sent either via queue or webhook. diff --git a/app/celery/nightly_tasks.py b/app/celery/nightly_tasks.py index 13823517b4..1b0c05e559 100644 --- a/app/celery/nightly_tasks.py +++ b/app/celery/nightly_tasks.py @@ -8,18 +8,13 @@ from app import db, notify_celery, performance_platform_client, zendesk_client from app.aws import s3 -from app.celery.service_callback_tasks import ( - send_delivery_status_to_service, - create_delivery_status_callback_data, -) -from app.config import QueueNames +from app.celery.service_callback_tasks import check_and_queue_callback_task from app.dao.inbound_sms_dao import delete_inbound_sms_older_than_retention from app.dao.jobs_dao import dao_get_jobs_older_than_data_retention, dao_archive_job from app.dao.notifications_dao import ( dao_timeout_notifications, delete_notifications_older_than_retention_by_type, ) -from app.dao.service_callback_api_dao import get_service_delivery_status_callback_api_for_service from app.exceptions import NotificationTechnicalFailureException from app.models import Notification, NOTIFICATION_SENDING, EMAIL_TYPE, SMS_TYPE, LETTER_TYPE, KEY_TYPE_NORMAL from app.performance_platform import total_sent_notifications, processing_time @@ -104,20 +99,14 @@ def delete_letter_notifications_older_than_retention(): @cronitor('timeout-sending-notifications') @statsd(namespace='tasks') def timeout_notifications(): + """A task that runs every night at 12:05 AM EST to update the status of notifications that have timed out.""" technical_failure_notifications, temporary_failure_notifications = dao_timeout_notifications( current_app.config.get('SENDING_NOTIFICATIONS_TIMEOUT_PERIOD') ) notifications = technical_failure_notifications + temporary_failure_notifications for notification in notifications: # queue callback task only if the service_callback_api exists - service_callback_api = get_service_delivery_status_callback_api_for_service( - service_id=notification.service_id, notification_status=notification.status - ) - if service_callback_api: - encrypted_notification = create_delivery_status_callback_data(notification, service_callback_api) - send_delivery_status_to_service.apply_async( - [service_callback_api.id, str(notification.id), encrypted_notification], queue=QueueNames.CALLBACKS - ) + check_and_queue_callback_task(notification) current_app.logger.info( 'Timeout period reached for {} notifications, status has been updated.'.format(len(notifications)) diff --git a/app/celery/service_callback_tasks.py b/app/celery/service_callback_tasks.py index fe1ecdaa11..bef304c5ba 100644 --- a/app/celery/service_callback_tasks.py +++ b/app/celery/service_callback_tasks.py @@ -1,7 +1,13 @@ +import json + +from celery import Task from flask import current_app +from requests import post +from requests.exceptions import Timeout, RequestException from notifications_utils.statsd_decorators import statsd from app import notify_celery, encryption, statsd_client, DATETIME_FORMAT +from app.callback.webhook_callback_strategy import generate_callback_signature from app.celery.exceptions import AutoRetryException, NonRetryableException, RetryableException from app.config import QueueNames from app.dao.complaint_dao import fetch_complaint_by_id @@ -278,7 +284,7 @@ def create_delivery_status_callback_data( return encryption.encrypt(data) -def create_delivery_status_callback_data_v3(notification: Notification) -> dict[str, str]: +def create_delivery_status_callback_data_v3(notification: Notification) -> dict[str, str | None]: """Create all data that will be sent to a callback url specified in the Notification object. Args: @@ -328,10 +334,7 @@ def create_complaint_callback_data( return encryption.encrypt(data) -def check_and_queue_callback_task( - notification, - payload=None, -): +def check_and_queue_service_callback_task(notification: Notification, payload=None): # https://peps.python.org/pep-0557/#mutable-default-values # do not want to have mutable type in definition so we set provider_payload to empty dictionary # when one was not provided by the caller @@ -355,6 +358,117 @@ def check_and_queue_callback_task( ) +@notify_celery.task( + bind=True, + name='send-notification-delivery-status', + throws=(AutoRetryException,), + autoretry_for=(AutoRetryException,), + max_retries=60, + retry_backoff=True, + retry_backoff_max=3600, +) +@statsd(namespace='tasks') +def send_delivery_status_from_notification( + self: Task, + callback_signature: str, + callback_url: str, + notification_data: dict[str, str], +) -> None: + """ + Send a delivery status notification to the given callback URL. + + Args: + callback_signature (str): Signature for the callback + callback_url (str): URL to send the callback to + notification_data (dict[str, str]): Data to send in the callback + + Raises: + AutoRetryException: If the request should be retried + NonRetryableException: If the request should not be retried + """ + try: + response = post( + url=callback_url, + data=json.dumps(notification_data), + headers={ + 'Content-Type': 'application/json', + 'x-enp-signature': callback_signature, + }, + timeout=(3.05, 1), + ) + response.raise_for_status() + except Timeout as e: + current_app.logger.warning( + 'Timeout error sending callback for notification %s, url %s', notification_data['id'], callback_url + ) + raise AutoRetryException(f'Found {type(e).__name__}, autoretrying...', e) + except RequestException as e: + # retryable error codes: + # 429: Too Many Requests + # 5xx: Server Error + if e.response is not None and e.response.status_code == 429 or e.response.status_code >= 500: + current_app.logger.warning( + 'Retryable error sending callback for notification %s, url %s | status code: %s, exception: %s', + notification_data.get('id'), + callback_url, + e.response.status_code if e.response is not None else 'unknown', + str(e), + ) + raise AutoRetryException(f'Found {type(e).__name__}, autoretrying...', e) + else: + current_app.logger.warning( + 'Non-retryable error sending callback for notification %s, url %s | status code: %s, exception: %s', + notification_data.get('id'), + callback_url, + e.response.status_code if e.response is not None else 'unknown', + str(e), + ) + raise NonRetryableException(f'Found {type(e).__name__}, will not retry.', e) + + current_app.logger.debug( + 'Callback successfully sent for notification %s, url: %s | status code: %d', + notification_data.get('id'), + callback_url, + response.status_code, + ) + + +def check_and_queue_notification_callback_task(notification: Notification) -> None: + """ + When a callback URL is included in the notification, collect the required information and + queue a task to send the callback. + + Args: + notification (Notification): Notification object + """ + notification_data = create_delivery_status_callback_data_v3(notification) + + callback_signature = generate_callback_signature(notification.api_key_id, notification_data) + + send_delivery_status_from_notification.apply_async( + [callback_signature, notification.callback_url, notification_data], + queue=QueueNames.CALLBACKS, + ) + + +def check_and_queue_callback_task( + notification: Notification, + payload: dict[str, str] | None = None, +): + """ + Check if a callback url is included in the notification and call the appropriate funcation to queue the callback. + + Args: + notification (Notification): a Notification object which includes the callback information + payload (dict[str, str]): the payload from the provider to include in the callback if the service requires it + """ + if notification.callback_url: + # payload is not passed here because the service callback indicates whether the payload should be included + check_and_queue_notification_callback_task(notification) + else: + check_and_queue_service_callback_task(notification, payload) + + def _check_and_queue_complaint_callback_task( complaint, notification, diff --git a/app/commands.py b/app/commands.py index 42eac147b0..1334d6409e 100644 --- a/app/commands.py +++ b/app/commands.py @@ -271,6 +271,7 @@ def replay_service_callbacks( service_id, notification_status, ): + # not updated for notification callback_url as it doesn't appear to be used print('Start send service callbacks for service: ', service_id) callback_api = get_service_delivery_status_callback_api_for_service( service_id=service_id, notification_status=notification_status diff --git a/app/notifications/aws_sns_status_callback.py b/app/notifications/aws_sns_status_callback.py index 99a44104c1..7eed5e515e 100644 --- a/app/notifications/aws_sns_status_callback.py +++ b/app/notifications/aws_sns_status_callback.py @@ -3,11 +3,11 @@ from flask import current_app, request, jsonify from http import HTTPStatus from app import statsd_client +from app.celery.service_callback_tasks import check_and_queue_callback_task from app.schema_validation import validate from app.schema_validation.definitions import uuid -from app.models import NOTIFICATION_FAILED, NOTIFICATION_DELIVERED +from app.models import NOTIFICATION_FAILED, NOTIFICATION_DELIVERED, NOTIFICATION_PENDING from app.dao.notifications_dao import dao_get_notification_by_reference, _update_notification_status -from app.notifications.process_client_response import process_service_callback SNS_STATUS_SUCCESS = 'SUCCESS' SNS_STATUS_FAILURE = 'FAILURE' @@ -61,6 +61,7 @@ def process_sns_delivery_status(): notification = _update_notification_status(notification, status) send_callback_metrics(notification) - process_service_callback(notification) + if notification.status != NOTIFICATION_PENDING: + check_and_queue_callback_task(notification) return jsonify({}), HTTPStatus.NO_CONTENT diff --git a/app/notifications/process_client_response.py b/app/notifications/process_client_response.py index eda1069412..b566a17fbc 100644 --- a/app/notifications/process_client_response.py +++ b/app/notifications/process_client_response.py @@ -10,13 +10,8 @@ from app.dao import notifications_dao from app.clients.sms.firetext import get_firetext_responses from app.clients.sms.mmg import get_mmg_responses -from app.celery.service_callback_tasks import ( - send_delivery_status_to_service, - create_delivery_status_callback_data, -) -from app.config import QueueNames +from app.celery.service_callback_tasks import check_and_queue_callback_task from app.dao.notifications_dao import dao_update_notification -from app.dao.service_callback_api_dao import get_service_delivery_status_callback_api_for_service from app.dao.templates_dao import dao_get_template_by_id from app.models import NOTIFICATION_PENDING @@ -81,7 +76,7 @@ def _process_for_status( notification_status, client_name, provider_reference, -): +) -> None | str: # record stats notification = notifications_dao.update_notification_status_by_id( notification_id=provider_reference, status=notification_status, sent_by=client_name.lower() @@ -110,31 +105,12 @@ def _process_for_status( notifications_dao.dao_update_notification(notification) if notification_status != NOTIFICATION_PENDING: - service_callback_api = get_service_delivery_status_callback_api_for_service( - service_id=notification.service_id, notification_status=notification.status - ) - # queue callback task only if the service_callback_api exists - if service_callback_api: - encrypted_notification = create_delivery_status_callback_data(notification, service_callback_api) - send_delivery_status_to_service.apply_async( - [service_callback_api.id, str(notification.id), encrypted_notification], queue=QueueNames.CALLBACKS - ) + check_and_queue_callback_task(notification) success = '{} callback succeeded. reference {} updated'.format(client_name, provider_reference) return success -def process_service_callback(notification): - if notification.status != NOTIFICATION_PENDING: - service_callback_api = get_service_delivery_status_callback_api_for_service(service_id=notification.service_id) - # queue callback task only if the service_callback_api exists - if service_callback_api: - encrypted_notification = create_delivery_status_callback_data(notification, service_callback_api) - send_delivery_status_to_service.apply_async( - [service_callback_api.id, notification.id, encrypted_notification], queue=QueueNames.CALLBACKS - ) - - def set_notification_sent_by( notification, client_name, diff --git a/tests/app/celery/test_service_callback_tasks.py b/tests/app/celery/test_service_callback_tasks.py index f058df905c..22257a9667 100644 --- a/tests/app/celery/test_service_callback_tasks.py +++ b/tests/app/celery/test_service_callback_tasks.py @@ -12,10 +12,12 @@ from app import DATETIME_FORMAT, encryption from app.celery.exceptions import AutoRetryException, NonRetryableException from app.celery.service_callback_tasks import ( + check_and_queue_notification_callback_task, send_complaint_to_service, send_complaint_to_vanotify, check_and_queue_callback_task, publish_complaint, + send_delivery_status_from_notification, send_inbound_sms_to_service, create_delivery_status_callback_data, create_delivery_status_callback_data_v3, @@ -264,28 +266,59 @@ def test_send_email_complaint_to_vanotify_fails( ) -def test_check_and_queue_callback_task_does_not_queue_task_if_service_callback_api_does_not_exist( +def test_check_and_queue_callback_task_does_not_queue_task_if_callback_does_not_exist( notify_api, mocker, + sample_notification, ): - mock_notification = create_mock_notification(mocker) + mock_notification = sample_notification() mocker.patch( 'app.celery.service_callback_tasks.get_service_delivery_status_callback_api_for_service', return_value=None ) - mock_send_delivery_status = mocker.patch( 'app.celery.service_callback_tasks.send_delivery_status_to_service.apply_async' ) + mock_notification_callback = mocker.patch( + 'app.celery.service_callback_tasks.check_and_queue_notification_callback_task' + ) check_and_queue_callback_task(mock_notification) mock_send_delivery_status.assert_not_called() + mock_notification_callback.assert_not_called() + + +def test_check_and_queue_callback_task_queues_task_if_notification_callback_exists( + notify_api, + mocker, + sample_notification, +): + notification = sample_notification(callback_url='https://test.com') + mock_get_service_callback = mocker.patch( + 'app.celery.service_callback_tasks.get_service_delivery_status_callback_api_for_service' + ) + mock_send_delivery_status = mocker.patch( + 'app.celery.service_callback_tasks.send_delivery_status_to_service.apply_async' + ) + mock_notification_callback = mocker.patch( + 'app.celery.service_callback_tasks.check_and_queue_notification_callback_task' + ) + + check_and_queue_callback_task(notification) + + # service level callback is not needed + mock_get_service_callback.assert_not_called() + mock_send_delivery_status.assert_not_called() + + # notification level callback called + mock_notification_callback.assert_called_once_with(notification) def test_check_and_queue_callback_task_queues_task_if_service_callback_api_exists( notify_api, mocker, + sample_notification, ): - mock_notification = create_mock_notification(mocker) + mock_notification = sample_notification() mock_service_callback_api = mocker.Mock(ServiceCallback) mock_notification_data = mocker.Mock() @@ -379,13 +412,6 @@ def get_complaint_notification_and_email(mocker): return complaint, notification, 'recipient1@example.com' -def create_mock_notification(mocker): - notification = mocker.Mock(Notification) - notification.id = uuid.uuid4() - notification.service_id = uuid.uuid4() - return notification - - def _set_up_test_data( notification_type, callback_type, @@ -556,3 +582,67 @@ def test_create_delivery_status_callback_data_v3( assert data['status_reason'] == notification.status_reason assert data['provider'] == notification.sent_by assert data['provider_payload'] is None + + +def test_check_and_queue_notification_callback_task_queues_task_with_proper_data(mocker, sample_notification): + test_url = 'https://test_url.com' + notification = sample_notification(callback_url=test_url) + + notification_data = {'callback_url': test_url} + callback_signature_value = '6842b32e800372de4079e20d6e7e753bad182e44f7f3e19a46fd8509889a0014' + + mocker.patch( + 'app.celery.service_callback_tasks.create_delivery_status_callback_data_v3', + return_value=notification_data, + ) + mocker.patch( + 'app.celery.service_callback_tasks.generate_callback_signature', + return_value='6842b32e800372de4079e20d6e7e753bad182e44f7f3e19a46fd8509889a0014', + ) + mock_delivery_status_from_notification = mocker.patch( + 'app.celery.service_callback_tasks.send_delivery_status_from_notification.apply_async' + ) + + check_and_queue_notification_callback_task(notification) + + mock_delivery_status_from_notification.assert_called_once_with( + [callback_signature_value, test_url, notification_data], + queue=QueueNames.CALLBACKS, + ) + + +def test_send_delivery_status_from_notification_posts_https_request_to_service(rmock): + callback_signature = '6842b32e800372de4079e20d6e7e753bad182e44f7f3e19a46fd8509889a0014' + callback_url = 'https://test_url.com/' + notification_data = {'callback_url': callback_url} + + rmock.post(callback_url, json=notification_data, status_code=200) + send_delivery_status_from_notification(callback_signature, callback_url, notification_data) + + assert rmock.call_count == 1 + assert rmock.request_history[0].url == callback_url + assert rmock.request_history[0].headers['Content-type'] == 'application/json' + assert rmock.request_history[0].headers['x-enp-signature'] == callback_signature + assert rmock.request_history[0].text == json.dumps(notification_data) + + +@pytest.mark.parametrize('status_code', [429, 500, 502]) +def test_send_delivery_status_from_notification_raises_auto_retry_exception(rmock, status_code): + callback_signature = '6842b32e800372de4079e20d6e7e753bad182e44f7f3e19a46fd8509889a0014' + callback_url = 'https://test_url.com/' + notification_data = {'callback_url': callback_url} + + rmock.post(callback_url, json=notification_data, status_code=status_code) + with pytest.raises(AutoRetryException): + send_delivery_status_from_notification(callback_signature, callback_url, notification_data) + + +@pytest.mark.parametrize('status_code', [400, 403, 404]) +def test_send_delivery_status_from_notification_raises_non_retryable_exception(rmock, status_code): + callback_signature = '6842b32e800372de4079e20d6e7e753bad182e44f7f3e19a46fd8509889a0014' + callback_url = 'https://test_url.com/' + notification_data = {'callback_url': callback_url} + + rmock.post(callback_url, json=notification_data, status_code=status_code) + with pytest.raises(NonRetryableException): + send_delivery_status_from_notification(callback_signature, callback_url, notification_data)