sendgrid-async 2.1.2__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sendgrid-async
3
- Version: 2.1.2
3
+ Version: 2.2.1
4
4
  Summary: SendGrid using an httpx client
5
5
  Home-page: https://github.com/EM51641/async-sendgrid-
6
6
  License: MIT
7
7
  Keywords: sendgrid,client,async
8
8
  Author: Elyes Mahjoubi
9
9
  Author-email: elyesmahjoubi@gmail.com
10
- Requires-Python: >=3.10,<4.0
10
+ Requires-Python: >=3.10
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
@@ -17,7 +17,9 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
20
21
  Requires-Dist: httpx (>=0.24.1,<0.29.0)
22
+ Requires-Dist: httpx-retries (>=0.4.0)
21
23
  Requires-Dist: opentelemetry-api (>=1.34.0,<2.0.0)
22
24
  Requires-Dist: opentelemetry-exporter-otlp-proto-grpc (>=1.34.0,<2.0.0)
23
25
  Requires-Dist: opentelemetry-sdk (>=1.34.0,<2.0.0)
@@ -93,6 +95,53 @@ sendgrid = SendgridAPI(
93
95
  )
94
96
  ```
95
97
 
98
+ ### Retry Configuration
99
+
100
+ By default, requests are automatically retried up to 5 times with exponential backoff on transient failures (429 Too Many Requests, 502, 503, 504, and timeouts).
101
+
102
+ The delay between retries is calculated as:
103
+
104
+ ```
105
+ delay = backoff_factor * (2 ** attempt) * random(1 - backoff_jitter, 1)
106
+ ```
107
+
108
+ With the defaults (`backoff_factor=0.5`, `backoff_jitter=1.0`), delays range from 0 to 1s, 0 to 2s, 0 to 4s, etc. Jitter prevents thundering herd problems when multiple clients retry simultaneously.
109
+
110
+ Customize the retry behavior through the connection pool:
111
+
112
+ ```python
113
+ from async_sendgrid import SendgridAPI
114
+ from async_sendgrid.pool import ConnectionPool
115
+
116
+ pool = ConnectionPool(
117
+ retry_attempts=3, # Maximum retry attempts (default: 5)
118
+ backoff_factor=1.0, # Backoff multiplier in seconds (default: 0.5)
119
+ backoff_jitter=0.0, # Jitter multiplier, 0 to 1 (default: 1.0)
120
+ )
121
+
122
+ sendgrid = SendgridAPI(
123
+ api_key="YOUR_API_KEY",
124
+ pool=pool,
125
+ )
126
+ ```
127
+
128
+ To disable retries entirely, set `retry_attempts=0`:
129
+
130
+ ```python
131
+ pool = ConnectionPool(retry_attempts=0)
132
+ ```
133
+
134
+ You can also override the retry strategy per call:
135
+
136
+ ```python
137
+ # Override both retry attempts and backoff for this call
138
+ response = await sendgrid.send(email, retry=10, backoff=1.0)
139
+
140
+ # Override just one (the other keeps the pool default)
141
+ response = await sendgrid.send(email, retry=3)
142
+ response = await sendgrid.send(email, backoff=0.1)
143
+ ```
144
+
96
145
  ### Send emails on behalf of another user
97
146
 
98
147
  Send emails on behalf of subusers:
@@ -66,6 +66,53 @@ sendgrid = SendgridAPI(
66
66
  )
67
67
  ```
68
68
 
69
+ ### Retry Configuration
70
+
71
+ By default, requests are automatically retried up to 5 times with exponential backoff on transient failures (429 Too Many Requests, 502, 503, 504, and timeouts).
72
+
73
+ The delay between retries is calculated as:
74
+
75
+ ```
76
+ delay = backoff_factor * (2 ** attempt) * random(1 - backoff_jitter, 1)
77
+ ```
78
+
79
+ With the defaults (`backoff_factor=0.5`, `backoff_jitter=1.0`), delays range from 0 to 1s, 0 to 2s, 0 to 4s, etc. Jitter prevents thundering herd problems when multiple clients retry simultaneously.
80
+
81
+ Customize the retry behavior through the connection pool:
82
+
83
+ ```python
84
+ from async_sendgrid import SendgridAPI
85
+ from async_sendgrid.pool import ConnectionPool
86
+
87
+ pool = ConnectionPool(
88
+ retry_attempts=3, # Maximum retry attempts (default: 5)
89
+ backoff_factor=1.0, # Backoff multiplier in seconds (default: 0.5)
90
+ backoff_jitter=0.0, # Jitter multiplier, 0 to 1 (default: 1.0)
91
+ )
92
+
93
+ sendgrid = SendgridAPI(
94
+ api_key="YOUR_API_KEY",
95
+ pool=pool,
96
+ )
97
+ ```
98
+
99
+ To disable retries entirely, set `retry_attempts=0`:
100
+
101
+ ```python
102
+ pool = ConnectionPool(retry_attempts=0)
103
+ ```
104
+
105
+ You can also override the retry strategy per call:
106
+
107
+ ```python
108
+ # Override both retry attempts and backoff for this call
109
+ response = await sendgrid.send(email, retry=10, backoff=1.0)
110
+
111
+ # Override just one (the other keeps the pool default)
112
+ response = await sendgrid.send(email, retry=3)
113
+ response = await sendgrid.send(email, backoff=0.1)
114
+ ```
115
+
69
116
  ### Send emails on behalf of another user
70
117
 
71
118
  Send emails on behalf of subusers:
@@ -0,0 +1,13 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from .sendgrid import SendgridAPI # noqa
4
+ from .pool import ConnectionPool # noqa
5
+
6
+ __version__ = "0.0.0-dev"
7
+
8
+ try:
9
+ __version__ = version("sendgrid-async")
10
+ except PackageNotFoundError:
11
+ pass
12
+
13
+ __all__ = ["SendgridAPI", "ConnectionPool"]
@@ -0,0 +1,155 @@
1
+ """
2
+ Connection pool manager for SendGrid API requests.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from httpx import AsyncClient, AsyncHTTPTransport, Limits # type: ignore
10
+ from httpx_retries import Retry, RetryTransport # type: ignore
11
+
12
+ if TYPE_CHECKING:
13
+ from typing import Any
14
+
15
+
16
+ class ConnectionPool:
17
+ """
18
+ A connection pool manager for SendGrid API requests.
19
+ This is a private class and is not meant to be used directly.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ max_connections: int = 10,
25
+ max_keepalive_connections: int = 5,
26
+ keepalive_expiry: float = 5.0,
27
+ retry_attempts: int = 5,
28
+ backoff_factor: float = 0.5,
29
+ backoff_jitter: float = 1.0,
30
+ ) -> None:
31
+ """
32
+ Initialize the connection pool.
33
+
34
+ Args:
35
+ max_connections (int, optional):
36
+ Maximum number of concurrent connections.
37
+ Defaults to 10.
38
+ max_keepalive_connections (int, optional):
39
+ Maximum number of keep-alive connections.
40
+ Defaults to 5.
41
+ keepalive_expiry (float, optional):
42
+ Keep-alive connection expiry time in
43
+ seconds. Defaults to 5.0.
44
+ retry_attempts (int, optional):
45
+ Maximum number of retry attempts for
46
+ transient failures (429, 5xx, timeouts).
47
+ Defaults to 5.
48
+ backoff_factor (float, optional):
49
+ Multiplier for exponential backoff between
50
+ retries. Defaults to 0.5.
51
+ backoff_jitter (float, optional):
52
+ Jitter multiplier applied to the backoff time,
53
+ between 0 and 1. Defaults to 1.0.
54
+ """
55
+ self._validate_retry_attempts(retry_attempts)
56
+ self._validate_backoff_factor(backoff_factor)
57
+ self._validate_backoff_jitter(backoff_jitter)
58
+
59
+ self._limits = Limits(
60
+ max_connections=max_connections,
61
+ max_keepalive_connections=max_keepalive_connections,
62
+ keepalive_expiry=keepalive_expiry,
63
+ )
64
+ self._retry = Retry(
65
+ total=retry_attempts,
66
+ backoff_factor=backoff_factor,
67
+ backoff_jitter=backoff_jitter,
68
+ allowed_methods=["POST"],
69
+ )
70
+ self._client: AsyncClient | None = None
71
+
72
+ def _create_client(
73
+ self,
74
+ headers: dict[str, Any],
75
+ retry: int | None = None,
76
+ backoff: float | None = None,
77
+ ) -> AsyncClient:
78
+ """
79
+ Get or create an HTTP client with the configured connection limits.
80
+
81
+ Args:
82
+ headers (dict[str, Any]): The headers to use for the client.
83
+ retry (int, optional): Override the number of retry attempts.
84
+ Uses the pool default when not set.
85
+ backoff (float, optional): Override the backoff factor.
86
+ Uses the pool default when not set.
87
+
88
+ Returns:
89
+ AsyncClient: The configured HTTP client.
90
+ """
91
+ if retry is not None:
92
+ self._validate_retry_attempts(retry)
93
+ if backoff is not None:
94
+ self._validate_backoff_factor(backoff)
95
+
96
+ retry_strategy = self._retry
97
+ if retry is not None or backoff is not None:
98
+ retry_strategy = Retry(
99
+ total=retry if retry is not None else self._retry.total,
100
+ backoff_factor=(
101
+ backoff
102
+ if backoff is not None
103
+ else self._retry.backoff_factor
104
+ ),
105
+ backoff_jitter=self._retry.backoff_jitter,
106
+ allowed_methods=["POST"],
107
+ )
108
+ transport = RetryTransport(
109
+ transport=AsyncHTTPTransport(limits=self._limits),
110
+ retry=retry_strategy,
111
+ )
112
+ return AsyncClient(
113
+ headers=headers,
114
+ timeout=5.0,
115
+ transport=transport,
116
+ )
117
+
118
+ @staticmethod
119
+ def _validate_retry_attempts(retry_attempts: int) -> None:
120
+ if not isinstance(retry_attempts, int) or retry_attempts < 0:
121
+ raise ValueError("retry_attempts must be a positive integer")
122
+
123
+ @staticmethod
124
+ def _validate_backoff_factor(backoff_factor: float) -> None:
125
+ if not isinstance(backoff_factor, (int, float)) or backoff_factor < 0:
126
+ raise ValueError("backoff_factor must be a positive number")
127
+
128
+ @staticmethod
129
+ def _validate_backoff_jitter(backoff_jitter: float) -> None:
130
+ if not isinstance(backoff_jitter, (int, float)) or backoff_jitter < 0:
131
+ raise ValueError("backoff_jitter must be a positive number")
132
+
133
+ @property
134
+ def limits(self) -> Limits:
135
+ """
136
+ Get the current connection limits.
137
+
138
+ Returns:
139
+ Limits: The current connection limits configuration.
140
+ """
141
+ return self._limits
142
+
143
+ def __repr__(self) -> str:
144
+ return (
145
+ f"ConnectionPool("
146
+ f"max_connections={self._limits.max_connections}, "
147
+ f"max_keepalive_connections={self._limits.max_keepalive_connections}, " # noqa: E501
148
+ f"keepalive_expiry={self._limits.keepalive_expiry}, "
149
+ f"retry_attempts={self._retry.total}, "
150
+ f"backoff_factor={self._retry.backoff_factor}, "
151
+ f"backoff_jitter={self._retry.backoff_jitter})"
152
+ )
153
+
154
+ def __str__(self) -> str:
155
+ return repr(self)
@@ -50,7 +50,12 @@ class BaseSendgridAPI(ABC):
50
50
  """Not implemented"""
51
51
 
52
52
  @abstractmethod
53
- async def send(self, message: Mail) -> Response:
53
+ async def send(
54
+ self,
55
+ message: Mail,
56
+ retry: Optional[int] = None,
57
+ backoff: Optional[float] = None,
58
+ ) -> Response:
54
59
  """Not implemented"""
55
60
 
56
61
 
@@ -116,7 +121,12 @@ class SendgridAPI(BaseSendgridAPI):
116
121
  return self._session
117
122
 
118
123
  @trace_client()
119
- async def send(self, email: Mail) -> Response:
124
+ async def send(
125
+ self,
126
+ email: Mail,
127
+ retry: Optional[int] = None,
128
+ backoff: Optional[float] = None,
129
+ ) -> Response:
120
130
  """
121
131
  Make a Twilio SendGrid v3 API request with the request body generated
122
132
  by the Mail object
@@ -124,16 +134,36 @@ class SendgridAPI(BaseSendgridAPI):
124
134
  Args:
125
135
  email: The Twilio SendGrid v3 API request body generated
126
136
  by the Mail object or dict
137
+ retry: Override the number of retry attempts for this
138
+ request. Uses the client default when not set.
139
+ backoff: Override the backoff factor for this request.
140
+ Uses the client default when not set.
127
141
 
128
142
  Returns:
129
143
  The Twilio SendGrid v3 API response
130
144
  """
131
145
  self._check_session_closed()
132
146
  json_message = email.get()
133
- response = await self._session.post(
134
- url=self._endpoint, json=json_message
147
+
148
+ if retry is not None or backoff is not None:
149
+ async with self._build_client(retry, backoff) as session:
150
+ return await self._send(session, json_message)
151
+
152
+ return await self._send(self._session, json_message)
153
+
154
+ async def _send(
155
+ self, client: AsyncClient, json_message: dict[str, Any]
156
+ ) -> Response:
157
+ return await client.post(url=self._endpoint, json=json_message)
158
+
159
+ def _build_client(
160
+ self,
161
+ retry: Optional[int] = None,
162
+ backoff: Optional[float] = None,
163
+ ) -> AsyncClient:
164
+ return self._pool._create_client(
165
+ self._headers, retry=retry, backoff=backoff
135
166
  )
136
- return response
137
167
 
138
168
  def _check_session_closed(self):
139
169
  """
@@ -146,8 +176,12 @@ class SendgridAPI(BaseSendgridAPI):
146
176
  logger.error("Session not initialized")
147
177
  raise SessionClosedException("Session not initialized")
148
178
 
149
- def __str__(self) -> str:
150
- return f"SendGrid API Client\n • Endpoint: {self._endpoint}\n"
151
-
152
179
  def __repr__(self) -> str:
153
- return f"SendgridAPI(endpoint={self._endpoint})"
180
+ return (
181
+ f"SendgridAPI("
182
+ f"endpoint={self._endpoint!r}, "
183
+ f"pool={self._pool!r})"
184
+ )
185
+
186
+ def __str__(self) -> str:
187
+ return repr(self)
@@ -75,11 +75,13 @@ def trace_client():
75
75
  return func
76
76
 
77
77
  @wraps(func)
78
- async def wrapper(self: SendgridAPI, email: Mail) -> Response:
78
+ async def wrapper(
79
+ self: SendgridAPI, email: Mail, **kwargs: Any
80
+ ) -> Response:
79
81
  span = create_span(_SPAN_NAME)
80
82
  try:
81
83
  set_sendgrid_metrics(span, email)
82
- response: Response = await func(self, email)
84
+ response: Response = await func(self, email, **kwargs)
83
85
  set_http_metrics(span, response)
84
86
  return response
85
87
  except Exception as exc:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "sendgrid-async"
3
- version = "2.1.2"
3
+ version = "2.2.1"
4
4
  description = "SendGrid using an httpx client"
5
5
  license = "MIT"
6
6
  authors = ["Elyes Mahjoubi <elyesmahjoubi@gmail.com>"]
@@ -14,6 +14,7 @@ classifiers = [
14
14
  "Programming Language :: Python :: 3.11",
15
15
  "Programming Language :: Python :: 3.12",
16
16
  "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
17
18
  ]
18
19
 
19
20
  packages = [
@@ -26,9 +27,10 @@ include = [
26
27
  ]
27
28
 
28
29
  [tool.poetry.dependencies]
29
- python = "^3.10"
30
+ python = ">=3.10"
30
31
  sendgrid = "^6.7.0"
31
32
  httpx = ">=0.24.1,<0.29.0"
33
+ httpx-retries = ">=0.4.0"
32
34
  opentelemetry-api = "^1.34.0"
33
35
  opentelemetry-sdk = "^1.34.0"
34
36
  opentelemetry-exporter-otlp-proto-grpc = "^1.34.0"
@@ -37,6 +39,7 @@ opentelemetry-exporter-otlp-proto-grpc = "^1.34.0"
37
39
  pytest = "^8.4.0"
38
40
  pytest-asyncio = "^1.0.0"
39
41
  pytest-cov = "^6.1.1"
42
+ pytest-httpserver = "^1.1.0"
40
43
  mypy = "^1.0.0"
41
44
  flake8 = "^7.2.0"
42
45
  black = "^25.1.0"
@@ -1,4 +0,0 @@
1
- from .sendgrid import SendgridAPI # noqa
2
-
3
- __version__ = "2.0.2"
4
- __all__ = ["SendgridAPI"]
@@ -1,72 +0,0 @@
1
- """
2
- Connection pool manager for SendGrid API requests.
3
- """
4
-
5
- from __future__ import annotations
6
-
7
- from typing import TYPE_CHECKING
8
-
9
- from httpx import AsyncClient, Limits # type: ignore
10
-
11
- if TYPE_CHECKING:
12
- from typing import Any
13
-
14
-
15
- class ConnectionPool:
16
- """
17
- A connection pool manager for SendGrid API requests.
18
- This is a private class and is not meant to be used directly.
19
- """
20
-
21
- def __init__(
22
- self,
23
- max_connections: int = 10,
24
- max_keepalive_connections: int = 5,
25
- keepalive_expiry: float = 5.0,
26
- ) -> None:
27
- """
28
- Initialize the connection pool.
29
-
30
- Args:
31
- max_connections (int, optional):
32
- Maximum number of concurrent connections. Defaults to 10.
33
- max_keepalive_connections (int, optional):
34
- Maximum number of keep-alive connections. Defaults to 5.
35
- keepalive_expiry (float, optional):
36
- Keep-alive connection expiry time in seconds. Defaults to 5.0.
37
- """
38
- self._limits = Limits(
39
- max_connections=max_connections,
40
- max_keepalive_connections=max_keepalive_connections,
41
- keepalive_expiry=keepalive_expiry,
42
- )
43
- self._client: AsyncClient | None = None
44
-
45
- def _create_client(self, headers: dict[str, Any]) -> AsyncClient:
46
- """
47
- Get or create an HTTP client with the configured connection limits.
48
-
49
- Args:
50
- headers (dict[str, Any]): The headers to use for the client.
51
-
52
- Returns:
53
- AsyncClient: The configured HTTP client.
54
- """
55
- return AsyncClient(headers=headers, limits=self._limits, timeout=5.0)
56
-
57
- @property
58
- def limits(self) -> Limits:
59
- """
60
- Get the current connection limits.
61
-
62
- Returns:
63
- Limits: The current connection limits configuration.
64
- """
65
- return self._limits
66
-
67
- def __str__(self) -> str:
68
- return (
69
- f"ConnectionPool(max_connections={self._limits.max_connections}, "
70
- f"max_keepalive={self._limits.max_keepalive_connections}, "
71
- f"keepalive_expiry={self._limits.keepalive_expiry})"
72
- )
File without changes