303 lines
12 KiB
Python
303 lines
12 KiB
Python
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
|