Squash merge fix/enterprise-api-error-handling into deploy/enterprise
Some checks failed
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled

This commit is contained in:
GareArc
2026-03-04 22:31:21 -08:00
parent eaea4ad6dd
commit 11f657019a
2 changed files with 102 additions and 0 deletions

View File

@@ -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

View File

@@ -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