import os import json import requests import warnings import random import time from types import ModuleType from .constants import HTTP_STATUS_CODE, ERROR_CODE, URL from . import resources, utility from .errors import (BadRequestError, GatewayError, ServerError) def capitalize_camel_case(string): return "".join(map(str.capitalize, string.split('_'))) # Create a dict of resource classes RESOURCE_CLASSES = {} for name, module in resources.__dict__.items(): if isinstance(module, ModuleType) and \ capitalize_camel_case(name) in module.__dict__: RESOURCE_CLASSES[name] = module.__dict__[capitalize_camel_case(name)] UTILITY_CLASSES = {} for name, module in utility.__dict__.items(): if isinstance(module, ModuleType) and name.capitalize() in module.__dict__: UTILITY_CLASSES[name] = module.__dict__[name.capitalize()] class Client: """Razorpay client class""" DEFAULTS = { 'base_url': URL.BASE_URL, 'max_retries': 5, 'initial_delay': 1, 'max_delay': 60, 'jitter': 0.25 } def __init__(self, session=None, auth=None, **options): """ Initialize a Client object with session, optional auth handler, and options """ self.session = session or requests.Session() self.auth = auth file_dir = os.path.dirname(__file__) self.cert_path = file_dir + '/ca-bundle.crt' self.base_url = self._set_base_url(**options) self.max_retries = options.get('max_retries', self.DEFAULTS['max_retries']) self.initial_delay = options.get('initial_delay', self.DEFAULTS['initial_delay']) self.max_delay = options.get('max_delay', self.DEFAULTS['max_delay']) self.jitter = options.get('jitter', self.DEFAULTS['jitter']) self.retry_enabled = False self.app_details = [] # intializes each resource # injecting this client object into the constructor for name, Klass in RESOURCE_CLASSES.items(): setattr(self, name, Klass(self)) for name, Klass in UTILITY_CLASSES.items(): setattr(self, name, Klass(self)) def _set_base_url(self, **options): base_url = self.DEFAULTS['base_url'] if 'base_url' in options: base_url = options['base_url'] del(options['base_url']) # Remove retry options from options if they exist options.pop('max_retries', None) options.pop('initial_delay', None) options.pop('max_delay', None) options.pop('jitter', None) return base_url def _update_user_agent_header(self, options): user_agent = "{}{} {}".format('Razorpay-Python/', self._get_version(), self._get_app_details_ua()) if 'headers' in options: options['headers']['User-Agent'] = user_agent else: options['headers'] = {'User-Agent': user_agent} return options def _get_version(self): version = "" try: # nosemgrep : gitlab.bandit.B110 # Try importlib.metadata first (modern approach) try: import importlib.metadata from importlib.metadata import PackageNotFoundError version = importlib.metadata.version("razorpay") except ImportError: # Fall back to pkg_resources import pkg_resources from pkg_resources import DistributionNotFound version = pkg_resources.require("razorpay")[0].version except (PackageNotFoundError, DistributionNotFound, NameError): # pragma: no cover # PackageNotFoundError: importlib.metadata couldn't find the package # DistributionNotFound: pkg_resources couldn't find the package # NameError: in case the exception classes aren't defined due to import issues # If all else fails, use the hardcoded version from the package version = "1.4.3" warnings.warn( "Could not detect razorpay package version. Using fallback version." "This may indicate an installation issue.", UserWarning, stacklevel=4 ) return version def _get_app_details_ua(self): app_details_ua = "" app_details = self.get_app_details() for app in app_details: if 'title' in app: app_ua = app['title'] if 'version' in app: app_ua += "/{}".format(app['version']) app_details_ua += "{} ".format(app_ua) return app_details_ua def set_app_details(self, app_details): self.app_details.append(app_details) def get_app_details(self): return self.app_details def enable_retry(self, retry_enabled=False): self.retry_enabled = retry_enabled def request(self, method, path, **options): """ Dispatches a request to the Razorpay HTTP API with retry mechanism """ options = self._update_user_agent_header(options) # Determine authentication type use_public_auth = options.pop('use_public_auth', False) auth_to_use = self.auth if use_public_auth: # For public auth, use key_id only if self.auth and isinstance(self.auth, tuple) and len(self.auth) >= 1: auth_to_use = (self.auth[0], '') # Use key_id only, empty key_secret # Inject device mode header if provided device_mode = options.pop('device_mode', None) if device_mode is not None: if 'headers' not in options: options['headers'] = {} options['headers']['X-Razorpay-Device-Mode'] = device_mode url = "{}{}".format(self.base_url, path) delay_seconds = self.initial_delay # If retry is not enabled, set max attempts to 1 max_attempts = self.max_retries if self.retry_enabled else 1 for attempt in range(max_attempts): try: response = getattr(self.session, method)(url, auth=auth_to_use, verify=self.cert_path, **options) if ((response.status_code >= HTTP_STATUS_CODE.OK) and (response.status_code < HTTP_STATUS_CODE.REDIRECT)): return json.dumps({}) if(response.status_code==204) else response.json() else: msg = "" code = "" json_response = response.json() if 'error' in json_response: if 'description' in json_response['error']: msg = json_response['error']['description'] if 'code' in json_response['error']: code = str(json_response['error']['code']) if str.upper(code) == ERROR_CODE.BAD_REQUEST_ERROR: raise BadRequestError(msg) elif str.upper(code) == ERROR_CODE.GATEWAY_ERROR: raise GatewayError(msg) elif str.upper(code) == ERROR_CODE.SERVER_ERROR: # nosemgrep : python.lang.maintainability.useless-ifelse.useless-if-body raise ServerError(msg) else: raise ServerError(msg) except requests.exceptions.ConnectionError as e: if self.retry_enabled and attempt < max_attempts - 1: # Don't sleep on the last attempt # Apply exponential backoff with jitter jitter_value = random.uniform(-self.jitter, self.jitter) jittered_delay = delay_seconds * (1 + jitter_value) # Cap the delay at max_delay actual_delay = min(jittered_delay, self.max_delay) print(f"ConnectionError: {e}. Retrying in {actual_delay:.2f} seconds... (Attempt {attempt + 1}/{max_attempts})") time.sleep(actual_delay) delay_seconds *= 2 # Exponential backoff for next attempt else: print(f"Connection failed." + (f" Max retries ({max_attempts}) exceeded." if self.retry_enabled else "")) raise except requests.exceptions.Timeout as e: if self.retry_enabled and attempt < max_attempts - 1: # Don't sleep on the last attempt # Apply exponential backoff with jitter jitter_value = random.uniform(-self.jitter, self.jitter) jittered_delay = delay_seconds * (1 + jitter_value) # Cap the delay at max_delay actual_delay = min(jittered_delay, self.max_delay) print(f"Timeout: {e}. Retrying in {actual_delay:.2f} seconds... (Attempt {attempt + 1}/{max_attempts})") time.sleep(actual_delay) delay_seconds *= 2 # Exponential backoff for next attempt else: print(f"Request timed out." + (f" Max retries ({max_attempts}) exceeded." if self.retry_enabled else "")) raise except requests.exceptions.RequestException as e: # For other request exceptions, don't retry print(f"Request error occurred: {e}") raise def get(self, path, params, **options): """ Parses GET request options and dispatches a request """ return self.request('get', path, params=params, **options) def post(self, path, data, **options): """ Parses POST request options and dispatches a request """ data, options = self._update_request(data, options) return self.request('post', path, data=data, **options) def patch(self, path, data, **options): """ Parses PATCH request options and dispatches a request """ data, options = self._update_request(data, options) return self.request('patch', path, data=data, **options) def delete(self, path, data, **options): """ Parses DELETE request options and dispatches a request """ data, options = self._update_request(data, options) return self.request('delete', path, data=data, **options) def put(self, path, data, **options): """ Parses PUT request options and dispatches a request """ data, options = self._update_request(data, options) return self.request('put', path, data=data, **options) def file(self, path, data, **options): fileDict = {} fieldDict = {} if('file' not in data): # if file is not exists in the dictionary data['file'] = "" fileDict['file'] = data['file'] # Create a dict of form fields for fields in data: if(fields != 'file'): fieldDict[str(fields)] = data[fields] return self.request('post', path, files=fileDict, data=fieldDict, **options) def _update_request(self, data, options): """ Updates The resource data and header options """ data = json.dumps(data) if 'headers' not in options: options['headers'] = {} options['headers'].update({'Content-type': 'application/json'}) return data, options