Squash merge fix/enterprise-api-error-handling into release/e-1.12.1

This commit is contained in:
GareArc 2026-03-11 15:43:54 -07:00
parent 0f938d453c
commit d105a2f568
No known key found for this signature in database
2 changed files with 77 additions and 54 deletions

View File

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

View File

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