From 7d2b1c562a4d40484787f1f199408cd331c37a23 Mon Sep 17 00:00:00 2001 From: saikumar1607 Date: Wed, 8 Oct 2025 10:30:25 +0000 Subject: [PATCH] Added new changes in sdk appconfiguration-python-sdk.git Signed-off-by: saikumar1607 --- .secrets.baseline | 4 +- README.md | 8 - ...ions.internal.utils.compute_percentage.rst | 7 + ...n.configurations.internal.utils.parser.rst | 7 + ...guration.configurations.internal.utils.rst | 2 + ibm_appconfiguration/appconfiguration.py | 18 +- .../configurations/configuration_handler.py | 78 +----- .../internal/common/config_constants.py | 2 + .../internal/common/config_messages.py | 1 - .../internal/utils/api_manager.py | 25 ++ .../configurations/internal/utils/socket.py | 253 +++++++++++++++--- ibm_appconfiguration/version.py | 2 +- setup.py | 2 +- .../configurations/utils/test_socket.py | 5 +- unit_tests/test_appconfiguration.py | 4 - 15 files changed, 279 insertions(+), 139 deletions(-) create mode 100644 docs/apis/ibm_appconfiguration.configurations.internal.utils.compute_percentage.rst create mode 100644 docs/apis/ibm_appconfiguration.configurations.internal.utils.parser.rst diff --git a/.secrets.baseline b/.secrets.baseline index c5032b8..d3f5209 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2025-09-24T13:09:33Z", + "generated_at": "2025-10-08T10:30:25Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -61,7 +61,7 @@ } ], "results": {}, - "version": "0.13.1+ibm.62.dss", + "version": "0.13.1+ibm.64.dss", "word_list": { "file": null, "hash": null diff --git a/README.md b/README.md index 1cbcbd7..ab8b2e8 100644 --- a/README.md +++ b/README.md @@ -321,14 +321,6 @@ def configuration_update(self): appconfig_client.register_configuration_update_listener(configuration_update) ``` -## Fetch latest data - -Fetch the latest configuration data. - -```py -appconfig_client.fetch_configurations() -``` - ## Enable debugger (Optional) Use this method to enable/disable the logging in SDK. diff --git a/docs/apis/ibm_appconfiguration.configurations.internal.utils.compute_percentage.rst b/docs/apis/ibm_appconfiguration.configurations.internal.utils.compute_percentage.rst new file mode 100644 index 0000000..4da5470 --- /dev/null +++ b/docs/apis/ibm_appconfiguration.configurations.internal.utils.compute_percentage.rst @@ -0,0 +1,7 @@ +ibm\_appconfiguration.configurations.internal.utils.compute\_percentage module +============================================================================== + +.. automodule:: ibm_appconfiguration.configurations.internal.utils.compute_percentage + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/apis/ibm_appconfiguration.configurations.internal.utils.parser.rst b/docs/apis/ibm_appconfiguration.configurations.internal.utils.parser.rst new file mode 100644 index 0000000..dc5fd17 --- /dev/null +++ b/docs/apis/ibm_appconfiguration.configurations.internal.utils.parser.rst @@ -0,0 +1,7 @@ +ibm\_appconfiguration.configurations.internal.utils.parser module +================================================================= + +.. automodule:: ibm_appconfiguration.configurations.internal.utils.parser + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/apis/ibm_appconfiguration.configurations.internal.utils.rst b/docs/apis/ibm_appconfiguration.configurations.internal.utils.rst index ef4dcaf..63224d7 100644 --- a/docs/apis/ibm_appconfiguration.configurations.internal.utils.rst +++ b/docs/apis/ibm_appconfiguration.configurations.internal.utils.rst @@ -8,10 +8,12 @@ Submodules :maxdepth: 4 ibm_appconfiguration.configurations.internal.utils.api_manager + ibm_appconfiguration.configurations.internal.utils.compute_percentage ibm_appconfiguration.configurations.internal.utils.connectivity ibm_appconfiguration.configurations.internal.utils.file_manager ibm_appconfiguration.configurations.internal.utils.logger ibm_appconfiguration.configurations.internal.utils.metering + ibm_appconfiguration.configurations.internal.utils.parser ibm_appconfiguration.configurations.internal.utils.socket ibm_appconfiguration.configurations.internal.utils.url_builder ibm_appconfiguration.configurations.internal.utils.validators diff --git a/ibm_appconfiguration/appconfiguration.py b/ibm_appconfiguration/appconfiguration.py index 0b265f4..a6e65f7 100644 --- a/ibm_appconfiguration/appconfiguration.py +++ b/ibm_appconfiguration/appconfiguration.py @@ -89,7 +89,6 @@ def __init__(self): self.__guid = '' self.__is_initialized = False self.__is_initialized_configuration = False - self.__is_loading = False AppConfiguration.__instance = self def use_private_endpoint(self, use_private_endpoint_param: bool): @@ -131,7 +130,6 @@ def init(self, region: str, guid: str, apikey: str): self.__region = region self.__guid = guid self.__is_initialized = True - self.__is_loading = False self.__setup_configuration_handler() def get_region(self) -> str: @@ -216,14 +214,7 @@ def set_context(self, collection_id: str, environment_id: str, configuration_fil self.__is_initialized_configuration = True self.__configuration_handler_instance.set_context(collection_id, environment_id, default_options) - self.__load_data_now() - - def fetch_configurations(self): - """Fetch the latest configurations""" - if self.__is_initialized and self.__is_initialized_configuration: - self.__load_data_now() - else: - Logger.error(config_messages.COLLECTION_INIT_ERROR) + self.__configuration_handler_instance.load_data() def __setup_configuration_handler(self): self.__configuration_handler_instance = ConfigurationHandler.get_instance() @@ -231,13 +222,6 @@ def __setup_configuration_handler(self): override_service_url=self.__override_service_url, use_private_endpoint=self.__use_private_endpoint) - def __load_data_now(self): - if self.__is_loading: - return - self.__is_loading = True - self.__configuration_handler_instance.load_data() - self.__is_loading = False - def register_configuration_update_listener(self, listener): """Register a listener for the Configuration changes. diff --git a/ibm_appconfiguration/configurations/configuration_handler.py b/ibm_appconfiguration/configurations/configuration_handler.py index 70eb519..bd6817a 100644 --- a/ibm_appconfiguration/configurations/configuration_handler.py +++ b/ibm_appconfiguration/configurations/configuration_handler.py @@ -20,7 +20,6 @@ from typing import Dict, List, Any from threading import Timer, Thread from ibm_appconfiguration.configurations.internal.common import config_messages, config_constants -from ibm_appconfiguration.version import __version__ from .internal.utils.logger import Logger from .internal.utils.parser import extract_configurations, format_config from .internal.utils.validators import Validators @@ -33,25 +32,13 @@ from .internal.utils.metering import Metering from .internal.utils.socket import Socket from .internal.utils.url_builder import URLBuilder -from .internal.utils.connectivity import Connectivity from .internal.utils.api_manager import APIManager -import sys -from time import sleep - -# Server max time out is assumed to be 1 week = 604800 seconds = 40320*15 -sys.setrecursionlimit(40320) - -# delay between each web socket connection retry -delay = 15 class ConfigurationHandler: """Internal class to handle the configuration""" __instance = None - # variable to keep track of server-client connection status - __is_alive = False - @staticmethod def get_instance(): """ Static access method. """ @@ -83,8 +70,6 @@ def __init__(self): self.__on_socket_retry = False self.__override_service_url = None self.__socket = None - self.__connectivity = None - self.__is_network_connected = True self.__api_manager = None self.__use_private_endpoint = False @@ -139,7 +124,6 @@ def set_context(self, collection_id: str, environment_id: str, options: dict): self.__bootstrap_file = options['bootstrap_file'] self.__persistent_cache_dir = options['persistent_cache_dir'] self.__is_initialized = True - self.__check_network() def load_data(self): """Load the configuration data""" @@ -202,28 +186,6 @@ def register_configuration_update_listener(self, listener): else: Logger.error(config_messages.CONFIGURATION_HANDLER_METHOD_ERROR) - def __check_network(self): - if self.__live_config_update_enabled: - if self.__connectivity is None: - self.__connectivity = Connectivity.get_instance() - self.__connectivity.add_connectivity_listener(self.__network_listener) - self.__connectivity.check_connection() - else: - self.__connectivity = None - - def __network_listener(self, is_connected: bool): - if not self.__live_config_update_enabled: - self.__connectivity = None - return - - if is_connected: - if not self.__is_network_connected: - self.__is_network_connected = True - self.__fetch_config_data() - else: - Logger.debug(config_messages.NO_INTERNET_CONNECTION_ERROR) - self.__is_network_connected = False - def get_properties(self) -> Dict[str, Property]: """Get the list of Property objects @@ -272,25 +234,15 @@ def __fetch_config_data(self): if self.__is_initialized: self.__fetch_from_api() self.__on_socket_retry = False - # Socket connection is a long-running background task, and is safe to run as daemon threads - config_thread = Thread(target=self.__start_web_socket, args=()) - config_thread.daemon = True - config_thread.start() + self.__start_web_socket() def __start_web_socket(self): - bearer_token = URLBuilder.get_iam_authenticator().token_manager.get_token() - headers = { - 'Authorization': 'Bearer ' + bearer_token, - 'User-Agent': '{0}/{1}'.format(config_constants.SDK_NAME, __version__) - } - if self.__socket: - self.__socket.cancel() - self.__socket = None + self.__socket = Socket() self.__socket.setup( url=URLBuilder.get_web_socket_url(), - headers=headers, - callback=self.__on_web_socket_callback + headers_provider=self.__api_manager.get_websocket_headers, + callback=self.__handle_socket_events ) def __load_configurations(self, data: dict): @@ -548,32 +500,20 @@ def __fetch_from_api(self): else: Logger.debug(config_messages.CONFIGURATION_HANDLER_INIT_ERROR) - def __on_web_socket_callback(self, message=None, error_state=None, - closed_state=None, open_state=None): + def __handle_socket_events(self, message=None, error_state=None, + closed_state=None, open_state=None): if message: - self.__is_alive = True + Logger.debug(f'Received message from websocket. {message}') self.__fetch_from_api() - Logger.debug(f'Received message from socket. {message}') elif error_state: - self.__is_alive = False - Logger.error(f'Received error from socket. {error_state}') - Logger.info('Reconnecting to server....') self.__on_socket_retry = True - sleep(delay) - self.__start_web_socket() elif closed_state: - self.__is_alive = False - Logger.error('Received close connection from socket.') - Logger.info('Reconnecting to server....') self.__on_socket_retry = True - sleep(delay) - self.__start_web_socket() elif open_state: - self.__is_alive = True + Logger.debug('Received opened connection from websocket.') if self.__on_socket_retry: self.__on_socket_retry = False self.__fetch_from_api() - Logger.debug('Received opened connection from socket.') else: Logger.error('Unknown Error inside the socket connection.') @@ -582,4 +522,4 @@ def is_connected(self) -> bool: Returns: boolean indicating connection status """ - return self.__is_alive + return self.__socket.is_connected() diff --git a/ibm_appconfiguration/configurations/internal/common/config_constants.py b/ibm_appconfiguration/configurations/internal/common/config_constants.py index c890eeb..17ccc84 100644 --- a/ibm_appconfiguration/configurations/internal/common/config_constants.py +++ b/ibm_appconfiguration/configurations/internal/common/config_constants.py @@ -24,3 +24,5 @@ DEFAULT_ROLLOUT_PERCENTAGE = '$default' DEFAULT_FEATURE_VALUE = '$default' DEFAULT_PROPERTY_VALUE = '$default' +WEBSOCKET_RECONNECT_DELAY = 15 # Constant delay between reconnection attempts for server errors +CUSTOM_SOCKET_CLOSE_REASON_CODE = 4001 diff --git a/ibm_appconfiguration/configurations/internal/common/config_messages.py b/ibm_appconfiguration/configurations/internal/common/config_messages.py index 19d1f09..d257a17 100644 --- a/ibm_appconfiguration/configurations/internal/common/config_messages.py +++ b/ibm_appconfiguration/configurations/internal/common/config_messages.py @@ -35,7 +35,6 @@ CONFIGURATION_HANDLER_METHOD_ERROR = "Invalid action in ConfigurationHandler. Should be a method/function" SINGLETON_EXCEPTION = "class must be initialized using the get_instance() method." FEATURE_INVALID = "Invalid feature_id - " -NO_INTERNET_CONNECTION_ERROR = 'No connection to internet. Please re-connect.' PROPERTY_INVALID = "Invalid property_id - " CONFIGURATIONS_FETCH_SUCCESS = "Successfully fetched the configurations." RETRY_AFTER_TWO_MINUTES = "Failed to fetch the configurations. Retrying after 2 minutes." diff --git a/ibm_appconfiguration/configurations/internal/utils/api_manager.py b/ibm_appconfiguration/configurations/internal/utils/api_manager.py index cfe8956..fff8c89 100644 --- a/ibm_appconfiguration/configurations/internal/utils/api_manager.py +++ b/ibm_appconfiguration/configurations/internal/utils/api_manager.py @@ -19,6 +19,8 @@ from typing import Optional, Union from ibm_cloud_sdk_core import BaseService, DetailedResponse, ApiException from requests.exceptions import RetryError + +from .logger import Logger from .url_builder import URLBuilder from ibm_appconfiguration.version import __version__ from ..common import config_constants @@ -99,3 +101,26 @@ def __remove_null_values(self, dictionary: dict) -> dict: if isinstance(dictionary, dict): return {k: v for (k, v) in dictionary.items() if v is not None} return dictionary + + def get_websocket_headers(self) -> dict: + """Get fresh headers for WebSocket connection with current authentication token. + This method retrieves a fresh authentication token and returns headers + suitable for WebSocket connections. It should be called each time a + WebSocket connection is established to ensure the token is valid. + + Returns: + dict: Headers dictionary containing Authorization and User-Agent + + Raises: + Exception: If token retrieval fails, the exception is propagated + to allow the caller to determine if reconnection should be attempted + """ + try: + bearer_token = URLBuilder.get_iam_authenticator().token_manager.get_token() + return { + 'Authorization': 'Bearer ' + bearer_token, + 'User-Agent': '{0}/{1}'.format(config_constants.SDK_NAME, __version__) + } + except Exception as e: + Logger.error(f"Failed to retrieve IAM token for WebSocket: {str(e)}") + raise diff --git a/ibm_appconfiguration/configurations/internal/utils/socket.py b/ibm_appconfiguration/configurations/internal/utils/socket.py index 0e8991f..06287a8 100644 --- a/ibm_appconfiguration/configurations/internal/utils/socket.py +++ b/ibm_appconfiguration/configurations/internal/utils/socket.py @@ -17,62 +17,245 @@ """ import ssl import websocket +import threading +from time import sleep + +from ibm_cloud_sdk_core import ApiException + +from .logger import Logger +from ..common import config_constants +from ..common.config_constants import WEBSOCKET_RECONNECT_DELAY class Socket: - """Class to handle the Web socket""" + """ + Class to handle the Web socket connection. + + Example usage: + # Create socket instance + socket = Socket() + + # Setup and connect + socket.setup(url="wss://example.com", headers={"Authorization": "Bearer token"}, callback=my_callback) + + # Check connection status + is_connected = socket.is_connected() + + # Disconnect and reconnect + socket.disconnect() + socket.connect() + + # Clean up when done + socket.cancel() + """ + def __init__(self): self.__callback = None self.ws_client = None + self.__url = None + self.__headers_provider = None + self.__should_reconnect = False + self.__is_connected = False + self.__websocket_thread = None - def setup(self, url, headers, callback): - """ Setup the socket. + def setup(self, url: str, headers_provider, callback) -> None: + """ + Setup the socket with connection parameters. If already connected, will disconnect first. Args: - url: Url for the socket - headers: Headers for the socket. - callback: Callback for the socket. + url: Websocket URL to connect to + headers_provider: Callable that returns fresh headers dict for the websocket connection + callback: Callback function for websocket events """ + if not callable(headers_provider): + Logger.error("headers_provider must be a callable") + return + + # If already connected with same parameters, do nothing + if (self.__url == url and + self.__headers_provider == headers_provider and + self.__callback == callback and + self.__is_connected): + return + + # Store new parameters + self.__url = url + self.__headers_provider = headers_provider self.__callback = callback - self.ws_client = websocket.WebSocketApp( - url, - on_open=self.on_open, - on_message=self.on_message, - on_error=self.on_error, - on_close=self.on_close, - header=headers - ) - self.ws_client.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) - def on_message(self, _, message): - """Socket on-message + # Disconnect if already connected + self.disconnect() - Args: - message: Message object from the socket + # Start new connection + self.connect() + + def connect(self) -> None: + """ + Explicitly start/restart the websocket connection. + Safe to call multiple times - will disconnect existing connection first. + """ + # Disconnect any existing connection + self.disconnect() + + # Start new connection + self.__should_reconnect = True + if not self.__websocket_thread or not self.__websocket_thread.is_alive(): + self.__websocket_thread = threading.Thread(target=self.__websocket_run) + self.__websocket_thread.daemon = True + self.__websocket_thread.start() + + def disconnect(self) -> None: + """ + Disconnect the websocket without canceling. Can be reconnected later. + """ + self.__should_reconnect = False + self.__is_connected = False + if self.ws_client: + try: + self.ws_client.close(status=config_constants.CUSTOM_SOCKET_CLOSE_REASON_CODE) + except Exception: + pass + self.ws_client = None + + def cancel(self) -> None: + """ + Permanently cancel the websocket. Cannot be reconnected after this. + """ + self.disconnect() + self.__url = None + self.__headers_provider = None + self.__callback = None + + def is_connected(self) -> bool: + """ + Check if websocket is currently connected. + + Returns: + bool: True if connected, False otherwise """ + return self.__is_connected + + def __websocket_run(self): + """Main websocket thread that handles connection and reconnection""" + while self.__should_reconnect: + try: + if not self.__url or not self.__headers_provider: + Logger.error("URL or headers_provider not configured") + break + + # Get fresh headers for each connection attempt + try: + current_headers = self.__headers_provider() + if not isinstance(current_headers, dict): + Logger.error("headers_provider must return a dictionary") + break + except ApiException as e: + Logger.error(f"Error getting headers: {str(e)}") + # Check if the exception is due to a client error (4xx) from IAM + if self.__is_token_client_error(e): + Logger.error("Token retrieval failed with client error (4xx). Stopping WebSocket reconnection.") + self.__should_reconnect = False + break + + # For other errors (5xx, network issues), retry after delay + if self.__should_reconnect: + Logger.debug(f"Reconnecting to websocket in {WEBSOCKET_RECONNECT_DELAY} seconds...") + sleep(WEBSOCKET_RECONNECT_DELAY) + continue + + self.ws_client = websocket.WebSocketApp( + self.__url, + on_open=self.on_open, + on_message=self.on_message, + on_error=self.on_error, + on_close=self.on_close, + header=current_headers + ) + + self.ws_client.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) + + if self.__should_reconnect: + Logger.debug(f"Reconnecting to websocket in {WEBSOCKET_RECONNECT_DELAY} seconds...") + sleep(WEBSOCKET_RECONNECT_DELAY) + + except Exception as e: + Logger.error(f"WebSocket error: {str(e)}") + if self.__should_reconnect: + Logger.debug(f"Reconnecting to websocket in {WEBSOCKET_RECONNECT_DELAY} seconds...") + sleep(WEBSOCKET_RECONNECT_DELAY) + + def __is_token_client_error(self, error) -> bool: + """Check if the error from token retrieval is a client-side error (4xx)""" + # Check for various IBM SDK exception types that might contain status codes + error_str = str(error).lower() + + # Common patterns for 4xx errors in IBM SDK exceptions + if any(code in error_str for code in ['400', '401', '403', '404']): + return True + + # Check if exception has a status_code or code attribute + status_code = getattr(error, 'status_code', None) or getattr(error, 'code', None) + if status_code is not None: + try: + status_code = int(status_code) + if 400 <= status_code < 500 and status_code != 429 and status_code != 499: + return True + except (ValueError, TypeError): + pass + + # Check if it's an ApiException from ibm_cloud_sdk_core + if hasattr(error, 'message') and hasattr(error, 'http_response'): + http_response = getattr(error, 'http_response', None) + if http_response and hasattr(http_response, 'status_code'): + status_code = http_response.status_code + if 400 <= status_code < 500 and status_code != 429 and status_code != 499: + return True + + return False + + def __is_client_error(self, error) -> bool: + """Check if the error is a client-side error (4xx)""" + if isinstance(error, websocket.WebSocketBadStatusException): + status_code = getattr(error, 'status_code', None) + if status_code is not None and 400 <= status_code < 500 and status_code != 429 and status_code != 499: + return True + return False + + def on_message(self, _, message): + """Socket on-message callback""" if message == 'test message': + Logger.debug("Received test message from server") return - self.__callback(message=message) + + if self.__callback: + self.__callback(message=message) def on_error(self, _, error): - """Socket on-error + """Socket on-error callback""" + self.__is_connected = False + if self.__is_client_error(error): + # Stop reconnecting on client-side errors + Logger.error(f"Websocket connect failed due to client error: {error}") + self.__should_reconnect = False + else: + Logger.error(f"Websocket connect failed due to server error: {error}. Reconnecting...") - Args: - error: Error object from the socket - """ - self.__callback(error_state=error) - self.ws_client.close() + if self.__callback: + self.__callback(error_state=error) def on_close(self, _, close_status_code, close_msg): - """Socket on-close call""" - self.__callback(closed_state='Closed the web_socket') + """Socket on-close callback""" + self.__is_connected = False + if close_status_code is not None and close_status_code == config_constants.CUSTOM_SOCKET_CLOSE_REASON_CODE: + self.__should_reconnect = False + + Logger.error(f"Websocket closed with code: {close_status_code} and message: {close_msg}. Reconnecting...") + if self.__callback: + self.__callback(closed_state='Closed the web_socket') def on_open(self, _): - """Socket on-open call""" - self.__callback(open_state='Opened the web_socket') + """Socket on-open callback""" + self.__is_connected = True - def cancel(self): - """ - Socket cancel. - """ - self.ws_client.close() + if self.__callback: + self.__callback(open_state='Opened the web_socket') diff --git a/ibm_appconfiguration/version.py b/ibm_appconfiguration/version.py index bc8d14e..f848f76 100644 --- a/ibm_appconfiguration/version.py +++ b/ibm_appconfiguration/version.py @@ -15,4 +15,4 @@ """ Version of ibm-appconfiguration-python-sdk """ -__version__ = '0.3.9' +__version__ = '0.4.0' diff --git a/setup.py b/setup.py index e9e3e96..14f645c 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ from setuptools import setup, find_packages NAME = "ibm-appconfiguration-python-sdk" -VERSION = "0.3.9" +VERSION = "0.4.0" # To install the library, run the following # # python setup.py install diff --git a/unit_tests/configurations/utils/test_socket.py b/unit_tests/configurations/utils/test_socket.py index 82b09e2..746623a 100644 --- a/unit_tests/configurations/utils/test_socket.py +++ b/unit_tests/configurations/utils/test_socket.py @@ -29,11 +29,14 @@ def callback(self, message=None, error_state=None, closed_state=None, open_state self.expected_closed_state = closed_state self.expected_open_state = open_state + def headers_provider(self): + return {} + def test_socket(self): self.__socket = Socket() self.__socket.setup( url="ws://testurl.com", - headers=[], + headers_provider=self.headers_provider, callback=self.callback ) diff --git a/unit_tests/test_appconfiguration.py b/unit_tests/test_appconfiguration.py index 9f2fdda..9f5aebe 100644 --- a/unit_tests/test_appconfiguration.py +++ b/unit_tests/test_appconfiguration.py @@ -51,10 +51,6 @@ def test_configuration_fetch(self): sut1.set_context("", "") self.assertIsNotNone(sut1.get_apikey()) - def test_configuration_fetch_feature_data(self): - sut1 = AppConfiguration.get_instance() - sut1.fetch_configurations() - def response(self): print('Get your Feature value NOW')