From d105a2f568d0071a6850ad378f1cb76b3e9a6efc Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 11 Mar 2026 15:43:54 -0700 Subject: [PATCH] Squash merge fix/enterprise-api-error-handling into release/e-1.12.1 --- api/app_factory.py | 50 ++++++------ api/services/enterprise/enterprise_service.py | 81 ++++++++++++------- 2 files changed, 77 insertions(+), 54 deletions(-) diff --git a/api/app_factory.py b/api/app_factory.py index 65a1690c15..472bfc4984 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -15,6 +15,31 @@ from services.feature_service import LicenseStatus logger = logging.getLogger(__name__) +# Console bootstrap APIs exempt from license check. +# Defined at module level to avoid per-request tuple construction. +# - system-features: license status for expiry UI (GlobalPublicStoreProvider) +# - setup: install/setup status check (AppInitializer) +# - init: init password validation for fresh install (InitPasswordPopup) +# - login: auto-login after setup completion (InstallForm) +# - 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/init", + "/console/api/login", + "/console/api/features", + "/console/api/account/profile", + "/console/api/workspaces/current", + "/console/api/version", + "/console/api/activate/check", +) + # ---------------------------- # Application Factory Function @@ -44,30 +69,7 @@ def create_flask_app_with_configs() -> DifyApp: 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) - # - init: init password validation for fresh install (InitPasswordPopup) - # - login: auto-login after setup completion (InstallForm) - # - 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/init", - "/console/api/login", - "/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) + is_exempt = any(request.path.startswith(p) for p in _CONSOLE_EXEMPT_PREFIXES) else: # webapp API is_exempt = request.path.startswith("/api/system-features") diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 8e1da916e6..4e6638ebd9 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import logging import uuid from datetime import datetime +from typing import TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -8,6 +11,9 @@ from configs import dify_config from extensions.ext_redis import redis_client from services.enterprise.base import EnterpriseRequest +if TYPE_CHECKING: + from services.feature_service import LicenseStatus + logger = logging.getLogger(__name__) DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS = 1.0 @@ -57,7 +63,7 @@ class DefaultWorkspaceJoinResult(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) @model_validator(mode="after") - def _check_workspace_id_when_joined(self) -> "DefaultWorkspaceJoinResult": + def _check_workspace_id_when_joined(self) -> DefaultWorkspaceJoinResult: if self.joined and not self.workspace_id: raise ValueError("workspace_id must be non-empty when joined is True") return self @@ -230,45 +236,60 @@ class EnterpriseService: 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. + def get_cached_license_status(cls) -> LicenseStatus | None: + """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. + Caches valid statuses (active/expiring) for 10 minutes. Invalid statuses + are not cached so license updates are picked up on the next request. - Returns license status string or None if unavailable. + Returns: + LicenseStatus enum value, or None if enterprise is disabled / unreachable. """ 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") + cached = cls._read_cached_license_status() + if cached is not None: + return cached + + return cls._fetch_and_cache_license_status() + + @classmethod + def _read_cached_license_status(cls) -> LicenseStatus | None: + """Read license status from Redis cache, returning None on miss or failure.""" + from services.feature_service import LicenseStatus + + try: + raw = redis_client.get(LICENSE_STATUS_CACHE_KEY) + if raw: + value = raw.decode("utf-8") if isinstance(raw, bytes) else raw + return LicenseStatus(value) + except Exception: + logger.debug("Failed to read license status from cache", exc_info=True) + return None + + @classmethod + def _fetch_and_cache_license_status(cls) -> LicenseStatus | None: + """Fetch license status from enterprise API and cache the result. + + Only caches valid statuses (active/expiring) so license updates + for invalid statuses are picked up on the next request. + """ + from services.feature_service import LicenseStatus - # Cache miss or failure — call enterprise API try: info = cls.get_info() license_info = info.get("License") - if license_info: - from services.feature_service import LicenseStatus + if not license_info: + return None - status = license_info.get("status", LicenseStatus.INACTIVE) - # Only cache valid statuses so license updates are picked up immediately - if status in (LicenseStatus.ACTIVE, LicenseStatus.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 + status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) + if status in (LicenseStatus.ACTIVE, LicenseStatus.EXPIRING): + try: + redis_client.setex(LICENSE_STATUS_CACHE_KEY, LICENSE_STATUS_CACHE_TTL, status) + except Exception: + logger.debug("Failed to cache license status", exc_info=True) + return status except Exception: - logger.exception("Failed to get enterprise license status") - + logger.debug("Failed to fetch enterprise license status", exc_info=True) return None