Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# uvx ruff format
c1de3e776998c4128593c584413d143f1e0e2bd4
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ jobs:

- name: Run unit tests
run: |
python -m unittest discover -v || true
python -m unittest discover -v
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

- name: Verify tag matches package version
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
PACKAGE_VERSION="$(cat chargebee/version.py | cut -d'"' -f2)"

echo "Tag version: $TAG_VERSION"
echo "Package version: $PACKAGE_VERSION"

if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo "❌ Tag version ($TAG_VERSION) does not match package version ($PACKAGE_VERSION)"
exit 1
fi
echo "✅ Tag matches package version."

- name: Set up Python
uses: actions/setup-python@v5
with:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
### v3.11.0b1 (2025-08-27)
* * *

### New Features
* Use `httpx` instead of `requests`, thereby adding support for asynchronous API requests.

### v3.10.0 (2025-08-25)
* * *
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@ customer = response.customer
card = response.card
```

### Async HTTP client

Starting with version `3.9.0`, the Chargebee Python SDK can optionally be configured to use an asynchronous HTTP client which uses `asyncio` to perform non-blocking requests. This can be enabled by passing the `use_async_client=True` argument to the constructor:

```python
cb_client = Chargebee(api_key="api_key", site="site", use_async_client=True)
```

When configured to use the async client, all model methods return a coroutine, which will have to be awaited to get the response:

```python
async def get_customers():
response = await cb_client.Customer.list(
cb_client.Customer.ListParams(
first_name=Filters.StringFilter(IS="John")
)
)
return response
```

Note: The async methods will have to be wrapped in an event loop during invocation. For example, the `asyncio.run` method can be used to run the above example:

```python
import asyncio
response = asyncio.run(get_customers())
```

### List API Request With Filter

For pagination, `offset` is the parameter that is being used. The value used for this parameter must be the value returned in `next_offset` parameter from the previous API call.
Expand Down
1 change: 0 additions & 1 deletion chargebee/api_error.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
class APIError(Exception):

def __init__(self, http_code, json_obj, headers=None):
Exception.__init__(self, json_obj.get("message"))
self.json_obj = json_obj
Expand Down
6 changes: 6 additions & 0 deletions chargebee/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@

if py_major_v >= 3:
from urllib.parse import urlencode, urlparse

# httpx supports trio and asyncio
try:
import trio as event_loop
except ImportError:
import asyncio as event_loop
1 change: 1 addition & 0 deletions chargebee/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Environment(object):
time_travel_retry_delay_ms = 3000
retry_config = RetryConfig()
enable_debug_logs = False
use_async_client = False

def __init__(self, options):
self.api_key = options["api_key"]
Expand Down
175 changes: 137 additions & 38 deletions chargebee/http_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@
import random
import re
import time
import ssl

import requests
import httpx

from chargebee import (
APIError,
PaymentError,
InvalidRequestError,
OperationFailedError,
)
from chargebee import compat, util
from chargebee import compat, util, environment
from chargebee.main import Chargebee
from chargebee.version import VERSION

Expand All @@ -30,31 +31,32 @@ def _basic_auth_str(username):
def request(
method,
url,
env,
env: environment.Environment,
params=None,
headers=None,
subDomain=None,
isJsonRequest=False,
options=None,
options={},
use_async_client=False,
):
if not env:
raise Exception("No environment configured.")
if headers is None:
headers = {}

headers = headers or {}
request_args = {"method": method.upper()}

retry_config = env.get_retry_config() if hasattr(env, "get_retry_config") else None
url = env.api_url(url, subDomain)

if method.lower() in ("get", "head", "delete"):
url = "%s?%s" % (url, compat.urlencode(params))
payload = None
else:
if isJsonRequest:
payload = params
headers["Content-type"] = "application/json;charset=UTF-8"
else:
payload = compat.urlencode(params)
headers["Content-type"] = "application/x-www-form-urlencoded"
match method.lower(), isJsonRequest:
case "get" | "head" | "delete", _:
request_args["params"] = params
case _, True:
headers["Content-Type"] = "application/json;charset=UTF-8"
request_args["json"] = params
case _, False:
headers["Content-Type"] = "application/x-www-form-urlencoded"
request_args["data"] = params

headers.update(
{
Expand All @@ -71,28 +73,42 @@ def request(
idempotency_key is None
and retry_config.is_enabled()
and method.lower() == "post"
and options["isIdempotent"]
and options.get("isIdempotent")
):
headers[Chargebee.idempotency_header] = util.generate_uuid_v4()

meta = compat.urlparse(url)
scheme = "https" if Chargebee.verify_ca_certs or env.protocol == "https" else "http"
full_url = f"{scheme}://{meta.netloc + meta.path + '?' + meta.query}"

timeout = httpx.Timeout(
None,
connect=env.connect_timeout,
read=env.read_timeout,
)

request_args = {
"method": method.upper(),
"timeout": (env.connect_timeout, env.read_timeout),
"data": payload,
**request_args,
"timeout": timeout,
"headers": headers,
"url": full_url,
}

if Chargebee.verify_ca_certs:
request_args["verify"] = Chargebee.ca_cert_path
ctx = ssl.create_default_context(cafile=Chargebee.ca_cert_path)
request_args["verify"] = ctx

return process_response(full_url, request_args, retry_config, env.enable_debug_logs)
if use_async_client:
return _process_response_async(
full_url, request_args, retry_config, env.enable_debug_logs
)
else:
return _process_response(
full_url, request_args, retry_config, env.enable_debug_logs
)


def process_response(url, request_args, retry_config, enable_debug_logs):
def _process_response(url, request_args, retry_config, enable_debug_logs):
retry_count = 0

while True:
Expand All @@ -107,30 +123,75 @@ def process_response(url, request_args, retry_config, enable_debug_logs):
}
)
)
if request_args["data"]:
_logger.debug("PAYLOAD: {0}".format(request_args["data"]))
if payload := request_args.get("json", request_args.get("data")):
_logger.debug("PAYLOAD: {0}".format(payload))

if retry_count > 0:
headers = request_args.get("headers", {})
headers["X-CB-Retry-Attempt"] = str(retry_count)
request_args["headers"] = headers

response = requests.request(**request_args)
_logger.debug(
f"{request_args['method']} Response: {response.status_code} - {response.text}"
return _make_request(request_args)

except Exception as err:
status_code = extract_status_code(err)

if not retry_config or not retry_config.is_enabled():
raise err

if status_code == 429:
delay_ms = parse_retry_after(err) or retry_config.get_delay_ms()
log(
f"Rate limit hit. Retrying in {delay_ms}ms",
"INFO",
enable_debug_logs,
)
sleep(delay_ms)
retry_count += 1
continue

if not should_retry(status_code, retry_count, retry_config):
log(
f"Request failed after {retry_count} retries: {str(err)}",
"ERROR",
enable_debug_logs,
)
raise err

delay_ms = calculate_backoff_delay(retry_count, retry_config.get_delay_ms())
log(
f"Retrying [{retry_count + 1}/{retry_config.get_max_retries()}] in {delay_ms}ms due to status {status_code}",
"INFO",
enable_debug_logs,
)
sleep(delay_ms)
retry_count += 1

try:
resp_json = compat.json.loads(response.text)
except Exception:
raise map_plaintext_to_error(response)

if response.status_code < 200 or response.status_code > 299:
handle_api_resp_error(
url, response.status_code, resp_json, response.headers
async def _process_response_async(url, request_args, retry_config, enable_debug_logs):
retry_count = 0

while True:
try:
_logger.debug(f"{request_args['method']} Request: {url}")
_logger.debug(
"HEADERS: {0}".format(
{
k: v
for k, v in request_args["headers"].items()
if k.lower() != "authorization"
}
)
)
if payload := request_args.get("json", request_args.get("data")):
_logger.debug("PAYLOAD: {0}".format(payload))

if retry_count > 0:
headers = request_args.get("headers", {})
headers["X-CB-Retry-Attempt"] = str(retry_count)
request_args["headers"] = headers

return resp_json, response.headers, response.status_code
return await _make_request_async(request_args)

except Exception as err:
status_code = extract_status_code(err)
Expand All @@ -145,7 +206,7 @@ def process_response(url, request_args, retry_config, enable_debug_logs):
"INFO",
enable_debug_logs,
)
sleep(delay_ms)
await sleep_async(delay_ms)
retry_count += 1
continue

Expand All @@ -163,10 +224,44 @@ def process_response(url, request_args, retry_config, enable_debug_logs):
"INFO",
enable_debug_logs,
)
sleep(delay_ms)
await sleep_async(delay_ms)
retry_count += 1


def _handle_response(request_args: dict, response: httpx.Response):
_logger.debug(
f"{request_args['method']} Response: {response.status_code} - {response.text}"
)

try:
resp_json = compat.json.loads(response.text)
except Exception:
raise map_plaintext_to_error(response)

if response.status_code < 200 or response.status_code > 299:
handle_api_resp_error(
request_args["url"], response.status_code, resp_json, response.headers
)

return resp_json, response.headers, response.status_code


def _make_request(request_args):
"""Make a synchronous HTTP request using httpx"""
verify = request_args.pop("verify", True)
with httpx.Client(verify=verify) as client:
response = client.request(**request_args)
return _handle_response(request_args, response)


async def _make_request_async(request_args):
"""Make an asynchronous HTTP request using httpx"""
verify = request_args.pop("verify", True)
async with httpx.AsyncClient(verify=verify) as client:
response = await client.request(**request_args)
return _handle_response(request_args, response)


def map_plaintext_to_error(response):
text = response.text
if "503" in text:
Expand Down Expand Up @@ -246,6 +341,10 @@ def sleep(milliseconds):
time.sleep(milliseconds / 1000.0)


async def sleep_async(milliseconds):
await compat.event_loop.sleep(milliseconds / 1000.0)


def log(message, level="INFO", enable_debug_logs=False):
if enable_debug_logs:
print(f"[{level}] {message}")
Expand Down
4 changes: 3 additions & 1 deletion chargebee/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

@dataclass
class Chargebee:

env: Environment = None
idempotency_header: str = "chargebee-idempotency-key"

Expand All @@ -22,6 +21,7 @@ def __init__(
protocol: str = None,
connection_time_out: int = None,
read_time_out: int = None,
use_async_client: bool = False,
):
self.env = Environment({"api_key": api_key, "site": site})
if chargebee_domain is not None:
Expand All @@ -32,6 +32,8 @@ def __init__(
self.update_connect_timeout_secs(connection_time_out)
if read_time_out is not None:
self.update_read_timeout_secs(read_time_out)
if use_async_client:
self.env.use_async_client = True
self.env.set_api_endpoint()

self.Addon = chargebee.Addon(self.env)
Expand Down
Loading