Skip to content

Commit 6e79be6

Browse files
authored
Merge pull request #82 from chargebee/feat/async-http-client
Replace requests with httpx to add support for async http client
2 parents db16bb4 + a1a15bb commit 6e79be6

167 files changed

Lines changed: 485 additions & 326 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.git-blame-ignore-revs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# uvx ruff format
2+
c1de3e776998c4128593c584413d143f1e0e2bd4

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ jobs:
3737
3838
- name: Run unit tests
3939
run: |
40-
python -m unittest discover -v || true
40+
python -m unittest discover -v

.github/workflows/release.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ jobs:
1414
- name: Checkout repository
1515
uses: actions/checkout@v4
1616

17+
- name: Verify tag matches package version
18+
run: |
19+
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
20+
PACKAGE_VERSION="$(cat chargebee/version.py | cut -d'"' -f2)"
21+
22+
echo "Tag version: $TAG_VERSION"
23+
echo "Package version: $PACKAGE_VERSION"
24+
25+
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
26+
echo "❌ Tag version ($TAG_VERSION) does not match package version ($PACKAGE_VERSION)"
27+
exit 1
28+
fi
29+
echo "✅ Tag matches package version."
30+
1731
- name: Set up Python
1832
uses: actions/setup-python@v5
1933
with:

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### v3.11.0b1 (2025-08-27)
2+
* * *
3+
4+
### New Features
5+
* Use `httpx` instead of `requests`, thereby adding support for asynchronous API requests.
16

27
### v3.10.0 (2025-08-25)
38
* * *

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,33 @@ customer = response.customer
8080
card = response.card
8181
```
8282

83+
### Async HTTP client
84+
85+
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:
86+
87+
```python
88+
cb_client = Chargebee(api_key="api_key", site="site", use_async_client=True)
89+
```
90+
91+
When configured to use the async client, all model methods return a coroutine, which will have to be awaited to get the response:
92+
93+
```python
94+
async def get_customers():
95+
response = await cb_client.Customer.list(
96+
cb_client.Customer.ListParams(
97+
first_name=Filters.StringFilter(IS="John")
98+
)
99+
)
100+
return response
101+
```
102+
103+
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:
104+
105+
```python
106+
import asyncio
107+
response = asyncio.run(get_customers())
108+
```
109+
83110
### List API Request With Filter
84111

85112
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.

chargebee/api_error.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
class APIError(Exception):
2-
32
def __init__(self, http_code, json_obj, headers=None):
43
Exception.__init__(self, json_obj.get("message"))
54
self.json_obj = json_obj

chargebee/compat.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@
1010

1111
if py_major_v >= 3:
1212
from urllib.parse import urlencode, urlparse
13+
14+
# httpx supports trio and asyncio
15+
try:
16+
import trio as event_loop
17+
except ImportError:
18+
import asyncio as event_loop

chargebee/environment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class Environment(object):
1111
time_travel_retry_delay_ms = 3000
1212
retry_config = RetryConfig()
1313
enable_debug_logs = False
14+
use_async_client = False
1415

1516
def __init__(self, options):
1617
self.api_key = options["api_key"]

chargebee/http_request.py

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@
55
import random
66
import re
77
import time
8+
import ssl
89

9-
import requests
10+
import httpx
1011

1112
from chargebee import (
1213
APIError,
1314
PaymentError,
1415
InvalidRequestError,
1516
OperationFailedError,
1617
)
17-
from chargebee import compat, util
18+
from chargebee import compat, util, environment
1819
from chargebee.main import Chargebee
1920
from chargebee.version import VERSION
2021

@@ -30,31 +31,32 @@ def _basic_auth_str(username):
3031
def request(
3132
method,
3233
url,
33-
env,
34+
env: environment.Environment,
3435
params=None,
3536
headers=None,
3637
subDomain=None,
3738
isJsonRequest=False,
38-
options=None,
39+
options={},
40+
use_async_client=False,
3941
):
4042
if not env:
4143
raise Exception("No environment configured.")
42-
if headers is None:
43-
headers = {}
44+
45+
headers = headers or {}
46+
request_args = {"method": method.upper()}
4447

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

48-
if method.lower() in ("get", "head", "delete"):
49-
url = "%s?%s" % (url, compat.urlencode(params))
50-
payload = None
51-
else:
52-
if isJsonRequest:
53-
payload = params
54-
headers["Content-type"] = "application/json;charset=UTF-8"
55-
else:
56-
payload = compat.urlencode(params)
57-
headers["Content-type"] = "application/x-www-form-urlencoded"
51+
match method.lower(), isJsonRequest:
52+
case "get" | "head" | "delete", _:
53+
request_args["params"] = params
54+
case _, True:
55+
headers["Content-Type"] = "application/json;charset=UTF-8"
56+
request_args["json"] = params
57+
case _, False:
58+
headers["Content-Type"] = "application/x-www-form-urlencoded"
59+
request_args["data"] = params
5860

5961
headers.update(
6062
{
@@ -71,28 +73,42 @@ def request(
7173
idempotency_key is None
7274
and retry_config.is_enabled()
7375
and method.lower() == "post"
74-
and options["isIdempotent"]
76+
and options.get("isIdempotent")
7577
):
7678
headers[Chargebee.idempotency_header] = util.generate_uuid_v4()
7779

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

84+
timeout = httpx.Timeout(
85+
None,
86+
connect=env.connect_timeout,
87+
read=env.read_timeout,
88+
)
89+
8290
request_args = {
83-
"method": method.upper(),
84-
"timeout": (env.connect_timeout, env.read_timeout),
85-
"data": payload,
91+
**request_args,
92+
"timeout": timeout,
8693
"headers": headers,
8794
"url": full_url,
8895
}
96+
8997
if Chargebee.verify_ca_certs:
90-
request_args["verify"] = Chargebee.ca_cert_path
98+
ctx = ssl.create_default_context(cafile=Chargebee.ca_cert_path)
99+
request_args["verify"] = ctx
91100

92-
return process_response(full_url, request_args, retry_config, env.enable_debug_logs)
101+
if use_async_client:
102+
return _process_response_async(
103+
full_url, request_args, retry_config, env.enable_debug_logs
104+
)
105+
else:
106+
return _process_response(
107+
full_url, request_args, retry_config, env.enable_debug_logs
108+
)
93109

94110

95-
def process_response(url, request_args, retry_config, enable_debug_logs):
111+
def _process_response(url, request_args, retry_config, enable_debug_logs):
96112
retry_count = 0
97113

98114
while True:
@@ -107,30 +123,75 @@ def process_response(url, request_args, retry_config, enable_debug_logs):
107123
}
108124
)
109125
)
110-
if request_args["data"]:
111-
_logger.debug("PAYLOAD: {0}".format(request_args["data"]))
126+
if payload := request_args.get("json", request_args.get("data")):
127+
_logger.debug("PAYLOAD: {0}".format(payload))
112128

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

118-
response = requests.request(**request_args)
119-
_logger.debug(
120-
f"{request_args['method']} Response: {response.status_code} - {response.text}"
134+
return _make_request(request_args)
135+
136+
except Exception as err:
137+
status_code = extract_status_code(err)
138+
139+
if not retry_config or not retry_config.is_enabled():
140+
raise err
141+
142+
if status_code == 429:
143+
delay_ms = parse_retry_after(err) or retry_config.get_delay_ms()
144+
log(
145+
f"Rate limit hit. Retrying in {delay_ms}ms",
146+
"INFO",
147+
enable_debug_logs,
148+
)
149+
sleep(delay_ms)
150+
retry_count += 1
151+
continue
152+
153+
if not should_retry(status_code, retry_count, retry_config):
154+
log(
155+
f"Request failed after {retry_count} retries: {str(err)}",
156+
"ERROR",
157+
enable_debug_logs,
158+
)
159+
raise err
160+
161+
delay_ms = calculate_backoff_delay(retry_count, retry_config.get_delay_ms())
162+
log(
163+
f"Retrying [{retry_count + 1}/{retry_config.get_max_retries()}] in {delay_ms}ms due to status {status_code}",
164+
"INFO",
165+
enable_debug_logs,
121166
)
167+
sleep(delay_ms)
168+
retry_count += 1
122169

123-
try:
124-
resp_json = compat.json.loads(response.text)
125-
except Exception:
126-
raise map_plaintext_to_error(response)
127170

128-
if response.status_code < 200 or response.status_code > 299:
129-
handle_api_resp_error(
130-
url, response.status_code, resp_json, response.headers
171+
async def _process_response_async(url, request_args, retry_config, enable_debug_logs):
172+
retry_count = 0
173+
174+
while True:
175+
try:
176+
_logger.debug(f"{request_args['method']} Request: {url}")
177+
_logger.debug(
178+
"HEADERS: {0}".format(
179+
{
180+
k: v
181+
for k, v in request_args["headers"].items()
182+
if k.lower() != "authorization"
183+
}
131184
)
185+
)
186+
if payload := request_args.get("json", request_args.get("data")):
187+
_logger.debug("PAYLOAD: {0}".format(payload))
188+
189+
if retry_count > 0:
190+
headers = request_args.get("headers", {})
191+
headers["X-CB-Retry-Attempt"] = str(retry_count)
192+
request_args["headers"] = headers
132193

133-
return resp_json, response.headers, response.status_code
194+
return await _make_request_async(request_args)
134195

135196
except Exception as err:
136197
status_code = extract_status_code(err)
@@ -145,7 +206,7 @@ def process_response(url, request_args, retry_config, enable_debug_logs):
145206
"INFO",
146207
enable_debug_logs,
147208
)
148-
sleep(delay_ms)
209+
await sleep_async(delay_ms)
149210
retry_count += 1
150211
continue
151212

@@ -163,10 +224,44 @@ def process_response(url, request_args, retry_config, enable_debug_logs):
163224
"INFO",
164225
enable_debug_logs,
165226
)
166-
sleep(delay_ms)
227+
await sleep_async(delay_ms)
167228
retry_count += 1
168229

169230

231+
def _handle_response(request_args: dict, response: httpx.Response):
232+
_logger.debug(
233+
f"{request_args['method']} Response: {response.status_code} - {response.text}"
234+
)
235+
236+
try:
237+
resp_json = compat.json.loads(response.text)
238+
except Exception:
239+
raise map_plaintext_to_error(response)
240+
241+
if response.status_code < 200 or response.status_code > 299:
242+
handle_api_resp_error(
243+
request_args["url"], response.status_code, resp_json, response.headers
244+
)
245+
246+
return resp_json, response.headers, response.status_code
247+
248+
249+
def _make_request(request_args):
250+
"""Make a synchronous HTTP request using httpx"""
251+
verify = request_args.pop("verify", True)
252+
with httpx.Client(verify=verify) as client:
253+
response = client.request(**request_args)
254+
return _handle_response(request_args, response)
255+
256+
257+
async def _make_request_async(request_args):
258+
"""Make an asynchronous HTTP request using httpx"""
259+
verify = request_args.pop("verify", True)
260+
async with httpx.AsyncClient(verify=verify) as client:
261+
response = await client.request(**request_args)
262+
return _handle_response(request_args, response)
263+
264+
170265
def map_plaintext_to_error(response):
171266
text = response.text
172267
if "503" in text:
@@ -246,6 +341,10 @@ def sleep(milliseconds):
246341
time.sleep(milliseconds / 1000.0)
247342

248343

344+
async def sleep_async(milliseconds):
345+
await compat.event_loop.sleep(milliseconds / 1000.0)
346+
347+
249348
def log(message, level="INFO", enable_debug_logs=False):
250349
if enable_debug_logs:
251350
print(f"[{level}] {message}")

chargebee/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
@dataclass
99
class Chargebee:
10-
1110
env: Environment = None
1211
idempotency_header: str = "chargebee-idempotency-key"
1312

@@ -22,6 +21,7 @@ def __init__(
2221
protocol: str = None,
2322
connection_time_out: int = None,
2423
read_time_out: int = None,
24+
use_async_client: bool = False,
2525
):
2626
self.env = Environment({"api_key": api_key, "site": site})
2727
if chargebee_domain is not None:
@@ -32,6 +32,8 @@ def __init__(
3232
self.update_connect_timeout_secs(connection_time_out)
3333
if read_time_out is not None:
3434
self.update_read_timeout_secs(read_time_out)
35+
if use_async_client:
36+
self.env.use_async_client = True
3537
self.env.set_api_endpoint()
3638

3739
self.Addon = chargebee.Addon(self.env)

0 commit comments

Comments
 (0)