diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index 6bd0e2fe8a3..be587079f28 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -353,7 +353,7 @@ class ChatApi(Resource): 404: "`not_found` : Conversation does not exist.", 429: ( "- `too_many_requests` : Too many concurrent requests for this app.\n" - "- `rate_limit_error` : The upstream model provider rate limit was exceeded." + "- `rate_limit_error` : The Dify Cloud workflow execution quota for this workspace has been reached." ), 500: "`internal_server_error` : Internal server error.", }, diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index b3308c76c46..ef6d2a35a57 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -315,8 +315,7 @@ class WorkflowRunApi(Resource): ), 429: ( "- `too_many_requests` : Too many concurrent requests for this app.\n" - "- `rate_limit_error` : The upstream model provider rate limit or the Dify Cloud workflow execution " - "quota was exceeded." + "- `rate_limit_error` : The Dify Cloud workflow execution quota for this workspace has been reached." ), 500: "`internal_server_error` : Internal server error.", }, @@ -430,8 +429,7 @@ class WorkflowRunByIdApi(Resource): 404: "`not_found` : Workflow not found.", 429: ( "- `too_many_requests` : Too many concurrent requests for this app.\n" - "- `rate_limit_error` : The upstream model provider rate limit or the Dify Cloud workflow execution " - "quota was exceeded." + "- `rate_limit_error` : The Dify Cloud workflow execution quota for this workspace has been reached." ), 500: "`internal_server_error` : Internal server error.", }, diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 679d04605e9..f5c26d3719a 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -338,7 +338,7 @@ Send a request to the chat application. | 401 | Unauthorized - invalid API token | | | 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | | | 404 | `not_found` : Conversation does not exist. | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The Dify Cloud workflow execution quota for this workspace has been reached. | | | 500 | `internal_server_error` : Internal server error. | | ### [POST] /chat-messages/{task_id}/stop @@ -485,7 +485,7 @@ Send a request to the chat application. | 401 | Unauthorized - invalid API token | | | 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | | | 404 | `not_found` : Conversation does not exist. | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The Dify Cloud workflow execution quota for this workspace has been reached. | | | 500 | `internal_server_error` : Internal server error. | | ### [POST] /chat-messages/{task_id}/stop @@ -2207,7 +2207,7 @@ Execute a workflow. Cannot be executed without a published workflow. | 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | | | 401 | Unauthorized - invalid API token | | | 403 | - `forbidden` : Token scope, app, or workspace access denied. - `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through Web App, Service API, OpenAPI, or MCP. | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit or the Dify Cloud workflow execution quota was exceeded. | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The Dify Cloud workflow execution quota for this workspace has been reached. | | | 500 | `internal_server_error` : Internal server error. | | ### [GET] /workflows/run/{workflow_run_id} @@ -2283,7 +2283,7 @@ Execute a specific workflow version identified by its ID. Useful for running a p | 401 | Unauthorized - invalid API token | | | 403 | - `forbidden` : Token scope, app, or workspace access denied. - `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. - `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP. | | | 404 | `not_found` : Workflow not found. | | -| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit or the Dify Cloud workflow execution quota was exceeded. | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The Dify Cloud workflow execution quota for this workspace has been reached. | | | 500 | `internal_server_error` : Internal server error. | | --- diff --git a/api/tests/unit_tests/controllers/service_api/app/test_completion.py b/api/tests/unit_tests/controllers/service_api/app/test_completion.py index 3a479a1d351..12f05078ba9 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_completion.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_completion.py @@ -36,14 +36,17 @@ from controllers.service_api.app.completion import ( from controllers.service_api.app.error import ( AgentNotPublishedError, AppUnavailableError, + CompletionRequestError, ConversationCompletedError, NotChatAppError, WorkflowVersionExecutionNotAllowedError, ) +from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.apps.agent_app.errors import AgentAppNotPublishedError from core.errors.error import QuotaExceededError from enums import CloudPlan, DeploymentEdition from graphon.model_runtime.errors.invoke import InvokeError +from graphon.model_runtime.errors.invoke import InvokeRateLimitError as ProviderInvokeRateLimitError from models.base import TypeBase from models.enums import ConversationFromSource, EndUserType from models.model import App, AppMode, Conversation, EndUser, IconType, Message @@ -553,6 +556,43 @@ class TestCompletionStopApiController: class TestChatApiController: + @pytest.mark.parametrize( + ("source_error", "http_error", "status_code", "error_code"), + [ + pytest.param(InvokeRateLimitError, InvokeRateLimitHttpError, 429, "rate_limit_error", id="cloud-quota"), + pytest.param( + ProviderInvokeRateLimitError, CompletionRequestError, 400, "completion_request_error", id="provider" + ), + ], + ) + def test_maps_rate_limits_by_source( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + orm_session: Session, + source_error: type[InvokeRateLimitError | ProviderInvokeRateLimitError], + http_error: type[InvokeRateLimitHttpError | CompletionRequestError], + status_code: int, + error_code: str, + ) -> None: + generate = Mock(side_effect=source_error("rate limit reached")) + monkeypatch.setattr(AppGenerateService, "generate", generate) + app_model, end_user, _, _ = _persist_completion_state(orm_session, AppMode.ADVANCED_CHAT) + + api = ChatApi() + handler = unwrap(api.post) + + with app.test_request_context( + "/chat-messages", method="POST", json={"inputs": {}, "query": "hi", "response_mode": "blocking"} + ): + with pytest.raises(http_error) as exc_info: + handler(api, session=orm_session, app_model=app_model, end_user=end_user) + + generate.assert_called_once() + assert exc_info.value.code == status_code + assert exc_info.value.error_code == error_code + assert exc_info.value.description == "rate limit reached" + def test_rejects_sandbox_plan_workflow_version( self, app: Flask, monkeypatch: pytest.MonkeyPatch, orm_session: Session ) -> None: diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index f631735796b..ba8f2d8582a 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -29,6 +29,7 @@ from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, NotFound from controllers.service_api.app.error import ( + CompletionRequestError, NotWorkflowAppError, TriggerWorkflowServiceModeUnavailableError, WorkflowVersionExecutionNotAllowedError, @@ -49,6 +50,7 @@ from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpErr from core.app.entities.app_invoke_entities import InvokeFrom from enums import CloudPlan, DeploymentEdition from graphon.enums import WorkflowExecutionStatus +from graphon.model_runtime.errors.invoke import InvokeRateLimitError as ProviderInvokeRateLimitError from models import Account from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser @@ -556,21 +558,51 @@ class TestWorkflowRunApi: handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) @pytest.mark.parametrize("sqlite_session", [()], indirect=True) - def test_rate_limit(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: + @pytest.mark.parametrize("api_class", [WorkflowRunApi, WorkflowRunByIdApi]) + @pytest.mark.parametrize( + ("source_error", "http_error", "status_code", "error_code"), + [ + pytest.param(InvokeRateLimitError, InvokeRateLimitHttpError, 429, "rate_limit_error", id="cloud-quota"), + pytest.param( + ProviderInvokeRateLimitError, CompletionRequestError, 400, "completion_request_error", id="provider" + ), + ], + ) + def test_maps_rate_limits_by_source( + self, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + config_overrides: Callable[..., None], + api_class: type[WorkflowRunApi | WorkflowRunByIdApi], + source_error: type[InvokeRateLimitError | ProviderInvokeRateLimitError], + http_error: type[InvokeRateLimitHttpError | CompletionRequestError], + status_code: int, + error_code: str, + ) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( - AppGenerateService, - "generate", - lambda *_args, **_kwargs: (_ for _ in ()).throw(InvokeRateLimitError("slow")), + BillingService, "get_info", Mock(return_value={"subscription": {"plan": CloudPlan.PROFESSIONAL}}) ) + generate = Mock(side_effect=source_error("rate limit reached")) + monkeypatch.setattr(AppGenerateService, "generate", generate) - api = WorkflowRunApi() + api = api_class() handler = unwrap(api.post) app_model = _make_app_model() end_user = _make_end_user() + kwargs: dict[str, str] = {"workflow_id": str(uuid.uuid4())} if isinstance(api, WorkflowRunByIdApi) else {} - with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}): - with pytest.raises(InvokeRateLimitHttpError): - handler(api, session=sqlite_session, app_model=app_model, end_user=end_user) + with app.test_request_context( + "/workflows/run", method="POST", json={"inputs": {}, "response_mode": "blocking"} + ): + with pytest.raises(http_error) as exc_info: + handler(api, session=sqlite_session, app_model=app_model, end_user=end_user, **kwargs) + + generate.assert_called_once() + assert exc_info.value.code == status_code + assert exc_info.value.error_code == error_code + assert exc_info.value.description == "rate limit reached" def test_trigger_workflow_returns_stable_unavailable_error( self, diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index de0845c9ebd..958f86827b2 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -298,9 +298,10 @@ def test_service_openapi_documents_decorator_user_contracts(): assert schema["properties"]["user"] == USER_PROPERTY_SCHEMA assert "user" in schema["required"] - for path in ("/workflows/run", "/workflows/{workflow_id}/run"): + for path in ("/chat-messages", "/workflows/run", "/workflows/{workflow_id}/run"): rate_limit_description = paths[path]["post"]["responses"]["429"]["description"] - assert "upstream model provider rate limit" in rate_limit_description + assert "upstream model provider rate limit" not in rate_limit_description + assert "too_many_requests" in rate_limit_description assert "Dify Cloud workflow execution quota" in rate_limit_description task_stop_user_descriptions = {