|
18 | 18 |
|
19 | 19 | from abc import ABC, abstractmethod |
20 | 20 | import logging |
| 21 | +import threading |
21 | 22 |
|
22 | 23 | import aiohttp |
23 | 24 | import requests |
|
30 | 31 | class BaseConfig(ABC): |
31 | 32 | """Base configuration class for all AI Defense SDK clients.""" |
32 | 33 |
|
| 34 | + _instances = {} |
| 35 | + _lock = threading.Lock() |
| 36 | + |
33 | 37 | DEFAULT_STATUS_FORCELIST = (429, 500, 502, 503, 504) |
34 | 38 | DEFAULT_BACKOFF_FACTOR = 0.5 |
35 | 39 | DEFAULT_TOTAL = 3 |
@@ -58,16 +62,44 @@ class BaseConfig(ABC): |
58 | 62 | "me-central-1": "https://uae.api.aidefense.security.cisco.com", |
59 | 63 | } |
60 | 64 |
|
| 65 | + # Backward-compat: map legacy short codes to canonical AWS region names. |
61 | 66 | _SHORT_REGION_MAP = { |
62 | 67 | "us": "us-west-2", |
63 | 68 | "eu": "eu-central-1", |
64 | 69 | "apj": "ap-northeast-1", |
65 | 70 | } |
66 | 71 |
|
| 72 | + def __new__(cls, *args, **kwargs): |
| 73 | + if cls is BaseConfig: |
| 74 | + raise TypeError("BaseConfig is abstract and cannot be instantiated directly") |
| 75 | + |
| 76 | + # Singleton constructor for Config. Ensures only one instance is created per subclass. |
| 77 | + # Acquiring a lock is expensive, so we only do it if we need to. Future initializations will be fast. |
| 78 | + # Once the lock is acquired, we check again to ensure no other thread has created an instance. |
| 79 | + if cls not in cls._instances: |
| 80 | + with cls._lock: |
| 81 | + if cls not in cls._instances: |
| 82 | + cls._instances[cls] = super().__new__(cls) |
| 83 | + |
| 84 | + return cls._instances[cls] |
| 85 | + |
67 | 86 | _logger = logging.getLogger("aidefense_sdk.config") |
68 | 87 |
|
69 | 88 | def __init__(self, *args, **kwargs): |
70 | | - self._initialize(*args, **kwargs) |
| 89 | + # Double-checked locking: fast path avoids the lock for already-init'd |
| 90 | + # singletons; the lock prevents concurrent first-time callers from both |
| 91 | + # running _initialize on the same instance. |
| 92 | + if not getattr(self, "_initialized", False): |
| 93 | + with self._lock: |
| 94 | + if not getattr(self, "_initialized", False): |
| 95 | + try: |
| 96 | + self._initialize(*args, **kwargs) |
| 97 | + self._initialized = True |
| 98 | + except Exception: |
| 99 | + self._instances.pop(type(self), None) |
| 100 | + raise |
| 101 | + elif args or kwargs: |
| 102 | + self._warn_if_params_differ(*args, **kwargs) |
71 | 103 |
|
72 | 104 | def _set_region(self, region: str): |
73 | 105 | if not isinstance(region, str): |
@@ -150,6 +182,53 @@ def _set_pool_config(self, pool_config: dict): |
150 | 182 | "pool_maxsize": pool_config.get("pool_maxsize", self.DEFAULT_POOL_MAXSIZE), |
151 | 183 | } |
152 | 184 |
|
| 185 | + _INIT_PARAM_NAMES = ( |
| 186 | + "region", "runtime_base_url", "management_base_url", "timeout", |
| 187 | + ) |
| 188 | + |
| 189 | + def _warn_if_params_differ(self, *args, **kwargs): |
| 190 | + """Log a warning when the singleton is re-requested with different parameters.""" |
| 191 | + merged = dict(zip(self._INIT_PARAM_NAMES, args)) |
| 192 | + merged.update(kwargs) |
| 193 | + |
| 194 | + _NORMALIZERS = { |
| 195 | + "region": self._normalize_requested_region, |
| 196 | + "runtime_base_url": self._normalize_url, |
| 197 | + "management_base_url": self._normalize_url, |
| 198 | + } |
| 199 | + diffs = [] |
| 200 | + for key in self._INIT_PARAM_NAMES: |
| 201 | + if key not in merged or merged[key] is None: |
| 202 | + continue |
| 203 | + requested = merged[key] |
| 204 | + normalizer = _NORMALIZERS.get(key) |
| 205 | + if normalizer: |
| 206 | + requested = normalizer(requested) |
| 207 | + current = getattr(self, key, None) |
| 208 | + if current is not None and requested != current: |
| 209 | + diffs.append(f"{key}={current!r} (requested {merged[key]!r})") |
| 210 | + if diffs: |
| 211 | + self._logger.warning( |
| 212 | + "%s singleton already initialized. Ignoring different " |
| 213 | + "parameters: %s. Construct %s once and share it, or clear " |
| 214 | + "%s._instances to re-initialize.", |
| 215 | + type(self).__name__, |
| 216 | + ", ".join(diffs), |
| 217 | + type(self).__name__, |
| 218 | + type(self).__name__, |
| 219 | + ) |
| 220 | + |
| 221 | + @staticmethod |
| 222 | + def _normalize_requested_region(region): |
| 223 | + """Map short-code regions to canonical names for comparison.""" |
| 224 | + _SHORT = {"us": "us-west-2", "eu": "eu-central-1", "apj": "ap-northeast-1"} |
| 225 | + return _SHORT.get(region, region) if isinstance(region, str) else region |
| 226 | + |
| 227 | + @staticmethod |
| 228 | + def _normalize_url(url): |
| 229 | + """Strip trailing slash to match stored value normalization.""" |
| 230 | + return url.rstrip("/") if isinstance(url, str) else url |
| 231 | + |
153 | 232 | @abstractmethod |
154 | 233 | def _initialize(self, *args, **kwargs): |
155 | 234 | pass |
|
0 commit comments