From 44fb074359343c72a8222342e81823ecdcb4aa0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Fri, 10 Jul 2026 16:50:25 +0800 Subject: [PATCH] fix(trigger): surface webhook trigger quota exceeded as 429 response (#38656) --- api/controllers/trigger/webhook.py | 12 +++++-- api/services/trigger/webhook_service.py | 8 +++-- .../test_webhook_service_relationships.py | 6 +++- .../controllers/trigger/test_webhook.py | 34 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py index 213704383c3..4747ab9c22d 100644 --- a/api/controllers/trigger/webhook.py +++ b/api/controllers/trigger/webhook.py @@ -7,6 +7,7 @@ from werkzeug.exceptions import NotFound, RequestEntityTooLarge from controllers.trigger import bp from core.trigger.debug.event_bus import TriggerDebugEventBus from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key +from services.errors.app import QuotaExceededError from services.trigger.webhook_service import RawWebhookDataDict, WebhookService logger = logging.getLogger(__name__) @@ -60,8 +61,15 @@ def handle_webhook(webhook_id: str): response_data, status_code = WebhookService.generate_webhook_response(node_config) return jsonify(response_data), status_code - except ValueError as e: - raise NotFound(str(e)) + except QuotaExceededError: + return jsonify( + { + "error": "Too Many Requests", + "message": "Trigger event quota exceeded. Please upgrade your plan.", + } + ), 429 + except ValueError as error: + raise NotFound(str(error)) except RequestEntityTooLarge: raise except Exception as e: diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 587048e2ccd..bc0dcc0b5d0 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -101,6 +101,7 @@ class WebhookService: - Mapping[str, Any]: The node configuration data Raises: + QuotaExceededError: If the app trigger is rate limited ValueError: If webhook not found, app trigger not found, trigger disabled, or workflow not found """ with Session(db.engine) as session: @@ -141,8 +142,10 @@ class WebhookService: # Only check enabled status if not in debug mode if app_trigger.status == AppTriggerStatus.RATE_LIMITED: - raise ValueError( - f"Webhook trigger is rate limited for webhook {webhook_id}, please upgrade your plan." + raise QuotaExceededError( + feature=QuotaType.TRIGGER.value, + tenant_id=webhook_trigger.tenant_id, + required=1, ) if app_trigger.status != AppTriggerStatus.ENABLED: @@ -799,6 +802,7 @@ class WebhookService: workflow: The workflow to execute Raises: + QuotaExceededError: If the tenant has exhausted its trigger quota ValueError: If tenant owner is not found Exception: If workflow execution fails """ diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py index 902134e053d..ddaee0cf051 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py @@ -201,9 +201,13 @@ class TestWebhookServiceLookupWithContainers: db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.RATE_LIMITED ) - with pytest.raises(ValueError, match="rate limited"): + with pytest.raises(QuotaExceededError) as exc_info: WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) + assert exc_info.value.feature == QuotaType.TRIGGER.value + assert exc_info.value.tenant_id == tenant.id + assert exc_info.value.required == 1 + def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_disabled( self, db_session_with_containers: Session, flask_app_with_containers: Flask ): diff --git a/api/tests/unit_tests/controllers/trigger/test_webhook.py b/api/tests/unit_tests/controllers/trigger/test_webhook.py index 91c793d292e..7e591af6e96 100644 --- a/api/tests/unit_tests/controllers/trigger/test_webhook.py +++ b/api/tests/unit_tests/controllers/trigger/test_webhook.py @@ -5,6 +5,7 @@ import pytest from werkzeug.exceptions import NotFound, RequestEntityTooLarge import controllers.trigger.webhook as module +from services.errors.app import QuotaExceededError @pytest.fixture(autouse=True) @@ -83,6 +84,39 @@ class TestHandleWebhook: assert status == 400 assert response["error"] == "Bad Request" + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data") + @patch.object( + module.WebhookService, + "trigger_workflow_execution", + side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1), + ) + def test_quota_exceeded(self, mock_trigger, mock_extract, mock_get): + mock_get.return_value = (DummyWebhookTrigger(), "workflow", "node_config") + mock_extract.return_value = {"input": "x"} + + response, status = module.handle_webhook("wh-1") + + assert status == 429 + assert response == { + "error": "Too Many Requests", + "message": "Trigger event quota exceeded. Please upgrade your plan.", + } + + @patch.object( + module.WebhookService, + "get_webhook_trigger_and_workflow", + side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1), + ) + def test_rate_limited(self, mock_get): + response, status = module.handle_webhook("wh-1") + + assert status == 429 + assert response == { + "error": "Too Many Requests", + "message": "Trigger event quota exceeded. Please upgrade your plan.", + } + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing")) def test_value_error_not_found(self, mock_get): with pytest.raises(NotFound):