From 11f657019a4cd98230cba7d5a68dfa7284804471 Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 4 Mar 2026 22:31:21 -0800 Subject: [PATCH] Squash merge fix/enterprise-api-error-handling into deploy/enterprise --- api/app_factory.py | 56 +++++++++++++++++++ api/services/enterprise/enterprise_service.py | 46 +++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/api/app_factory.py b/api/app_factory.py index 11568f139f..09ee1ee6ee 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -1,13 +1,16 @@ import logging import time +from flask import request from opentelemetry.trace import get_current_span from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID from configs import dify_config from contexts.wrapper import RecyclableContextVar +from controllers.console.error import UnauthorizedAndForceLogout from core.logging.context import init_request_context from dify_app import DifyApp +from services.enterprise.enterprise_service import EnterpriseService logger = logging.getLogger(__name__) @@ -31,6 +34,59 @@ def create_flask_app_with_configs() -> DifyApp: init_request_context() RecyclableContextVar.increment_thread_recycles() + # Enterprise license validation for API endpoints (both console and webapp) + # When license expires, block all API access except bootstrap endpoints needed + # for the frontend to load the license expiration page without infinite reloads. + if dify_config.ENTERPRISE_ENABLED: + is_console_api = request.path.startswith("/console/api") + is_webapp_api = request.path.startswith("/api") and not is_console_api + + if is_console_api or is_webapp_api: + if is_console_api: + # Console bootstrap APIs exempt from license check: + # - system-features: license status for expiry UI (GlobalPublicStoreProvider) + # - setup: install/setup status check (AppInitializer) + # - features: billing/plan features (ProviderContextProvider) + # - account/profile: login check + user profile (AppContextProvider, useIsLogin) + # - workspaces/current: workspace + model providers (AppContextProvider) + # - version: version check (AppContextProvider) + # - activate/check: invitation link validation (signin page) + # Without these exemptions, the signin page triggers location.reload() + # on unauthorized_and_force_logout, causing an infinite loop. + console_exempt_prefixes = ( + "/console/api/system-features", + "/console/api/setup", + "/console/api/features", + "/console/api/account/profile", + "/console/api/workspaces/current", + "/console/api/version", + "/console/api/activate/check", + ) + is_exempt = any(request.path.startswith(p) for p in console_exempt_prefixes) + else: # webapp API + is_exempt = request.path.startswith("/api/system-features") + + if not is_exempt: + try: + # Check license status with caching (10 min TTL) + license_status = EnterpriseService.get_cached_license_status() + if license_status in ["inactive", "expired", "lost"]: + # Cookie clearing is handled by register_external_error_handlers + # in libs/external_api.py which detects the error code and calls + # build_force_logout_cookie_headers(). Frontend then checks + # code === 'unauthorized_and_force_logout' and calls location.reload(). + raise UnauthorizedAndForceLogout( + f"Enterprise license is {license_status}. " + "Please contact your administrator." + ) + except UnauthorizedAndForceLogout: + raise + except Exception: + # If license check fails, log but don't block the request. + # This prevents service disruption if enterprise API is temporarily + # unavailable. + logger.exception("Failed to check enterprise license status") + # add after request hook for injecting trace headers from OpenTelemetry span context # Only adds headers when OTEL is enabled and has valid context @dify_app.after_request diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 7a6ebf7c3f..c2d89283a6 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -5,12 +5,16 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, model_validator from configs import dify_config +from extensions.ext_redis import redis_client from services.enterprise.base import EnterpriseRequest logger = logging.getLogger(__name__) DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS = 1.0 ALLOWED_ACCESS_MODES = ["public", "private", "private_all", "sso_verified"] +# License status cache configuration +LICENSE_STATUS_CACHE_KEY = "enterprise:license:status" +LICENSE_STATUS_CACHE_TTL = 600 # 10 minutes class WebAppSettings(BaseModel): @@ -224,3 +228,45 @@ class EnterpriseService: params = {"appId": app_id} EnterpriseRequest.send_request("DELETE", "/webapp/clean", params=params) + + @classmethod + def get_cached_license_status(cls): + """ + Get enterprise license status with Redis caching to reduce HTTP calls. + + Only caches valid statuses (active/expiring) since invalid statuses + should be re-checked every request — the admin may update the license + at any time. + + Returns license status string or None if unavailable. + """ + if not dify_config.ENTERPRISE_ENABLED: + return None + + # Try cache first — only valid statuses are cached + try: + cached_status = redis_client.get(LICENSE_STATUS_CACHE_KEY) + if cached_status: + if isinstance(cached_status, bytes): + cached_status = cached_status.decode("utf-8") + return cached_status + except Exception: + logger.debug("Failed to get license status from cache, calling enterprise API") + + # Cache miss or failure — call enterprise API + try: + info = cls.get_info() + license_info = info.get("License") + if license_info: + status = license_info.get("status", "inactive") + # Only cache valid statuses so license updates are picked up immediately + if status in ("active", "expiring"): + try: + redis_client.setex(LICENSE_STATUS_CACHE_KEY, LICENSE_STATUS_CACHE_TTL, status) + except Exception: + logger.debug("Failed to cache license status") + return status + except Exception: + logger.exception("Failed to get enterprise license status") + + return None \ No newline at end of file