sendgrid-async 2.2.0__tar.gz → 2.3.0__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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sendgrid-async
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: SendGrid using an httpx client
5
5
  Home-page: https://github.com/EM51641/async-sendgrid-
6
6
  License: MIT
@@ -131,17 +131,22 @@ To disable retries entirely, set `retry_attempts=0`:
131
131
  pool = ConnectionPool(retry_attempts=0)
132
132
  ```
133
133
 
134
- You can also override the retry strategy per call:
134
+ ### Shutdown
135
+
136
+ When your application is shutting down, call `shutdown()` on the pool to close all connections and release resources:
135
137
 
136
138
  ```python
137
- # Override both retry attempts and backoff for this call
138
- response = await sendgrid.send(email, retry=10, backoff=1.0)
139
+ pool = ConnectionPool()
140
+ sendgrid = SendgridAPI(api_key="YOUR_API_KEY", pool=pool)
141
+
142
+ # ... send emails ...
139
143
 
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)
144
+ # On application shutdown
145
+ await pool.shutdown()
143
146
  ```
144
147
 
148
+ > **Note:** Per-call `retry` or `backoff` overrides on `send()` create and tear down an ephemeral connection (TCP + TLS handshake) for every request. Because an email send is a lightweight operation — a small JSON payload answered with a `202` — the connection overhead can easily exceed the request itself. Configure retry and backoff on the `ConnectionPool` at initialization instead.
149
+
145
150
  ### Send emails on behalf of another user
146
151
 
147
152
  Send emails on behalf of subusers:
@@ -102,17 +102,22 @@ To disable retries entirely, set `retry_attempts=0`:
102
102
  pool = ConnectionPool(retry_attempts=0)
103
103
  ```
104
104
 
105
- You can also override the retry strategy per call:
105
+ ### Shutdown
106
+
107
+ When your application is shutting down, call `shutdown()` on the pool to close all connections and release resources:
106
108
 
107
109
  ```python
108
- # Override both retry attempts and backoff for this call
109
- response = await sendgrid.send(email, retry=10, backoff=1.0)
110
+ pool = ConnectionPool()
111
+ sendgrid = SendgridAPI(api_key="YOUR_API_KEY", pool=pool)
112
+
113
+ # ... send emails ...
110
114
 
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)
115
+ # On application shutdown
116
+ await pool.shutdown()
114
117
  ```
115
118
 
119
+ > **Note:** Per-call `retry` or `backoff` overrides on `send()` create and tear down an ephemeral connection (TCP + TLS handshake) for every request. Because an email send is a lightweight operation — a small JSON payload answered with a `202` — the connection overhead can easily exceed the request itself. Configure retry and backoff on the `ConnectionPool` at initialization instead.
120
+
116
121
  ### Send emails on behalf of another user
117
122
 
118
123
  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"]
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  from typing import TYPE_CHECKING
8
8
 
9
- from httpx import AsyncClient, Limits # type: ignore
9
+ from httpx import AsyncClient, AsyncHTTPTransport, Limits # type: ignore
10
10
  from httpx_retries import Retry, RetryTransport # type: ignore
11
11
 
12
12
  if TYPE_CHECKING:
@@ -27,6 +27,7 @@ class ConnectionPool:
27
27
  retry_attempts: int = 5,
28
28
  backoff_factor: float = 0.5,
29
29
  backoff_jitter: float = 1.0,
30
+ timeout: float = 5.0,
30
31
  ) -> None:
31
32
  """
32
33
  Initialize the connection pool.
@@ -51,10 +52,13 @@ class ConnectionPool:
51
52
  backoff_jitter (float, optional):
52
53
  Jitter multiplier applied to the backoff time,
53
54
  between 0 and 1. Defaults to 1.0.
55
+ timeout (float, optional):
56
+ Request timeout in seconds. Defaults to 5.0.
54
57
  """
55
58
  self._validate_retry_attempts(retry_attempts)
56
59
  self._validate_backoff_factor(backoff_factor)
57
60
  self._validate_backoff_jitter(backoff_jitter)
61
+ self._validate_timeout(timeout)
58
62
 
59
63
  self._limits = Limits(
60
64
  max_connections=max_connections,
@@ -67,16 +71,44 @@ class ConnectionPool:
67
71
  backoff_jitter=backoff_jitter,
68
72
  allowed_methods=["POST"],
69
73
  )
74
+ self._timeout = timeout
70
75
  self._client: AsyncClient | None = None
76
+ self._shutdown = False
71
77
 
72
- def _create_client(
78
+ def _create_client(self, headers: dict[str, Any]) -> AsyncClient:
79
+ """
80
+ Get or create the long-lived HTTP client backed by the pool.
81
+
82
+ Args:
83
+ headers (dict[str, Any]): The headers to use for the client.
84
+
85
+ Returns:
86
+ AsyncClient: The configured HTTP client.
87
+ """
88
+ if self._client is not None and not self._client.is_closed:
89
+ return self._client
90
+
91
+ transport = RetryTransport(
92
+ transport=AsyncHTTPTransport(limits=self._limits),
93
+ retry=self._retry,
94
+ )
95
+ self._client = AsyncClient(
96
+ headers=headers,
97
+ timeout=self._timeout,
98
+ transport=transport,
99
+ )
100
+ return self._client
101
+
102
+ def _create_ephemeral_client(
73
103
  self,
74
104
  headers: dict[str, Any],
75
105
  retry: int | None = None,
76
106
  backoff: float | None = None,
77
107
  ) -> AsyncClient:
78
108
  """
79
- Get or create an HTTP client with the configured connection limits.
109
+ Create a single-use HTTP client with custom retry/backoff settings.
110
+
111
+ The caller is responsible for closing the returned client.
80
112
 
81
113
  Args:
82
114
  headers (dict[str, Any]): The headers to use for the client.
@@ -86,28 +118,43 @@ class ConnectionPool:
86
118
  Uses the pool default when not set.
87
119
 
88
120
  Returns:
89
- AsyncClient: The configured HTTP client.
121
+ AsyncClient: An ephemeral HTTP client.
90
122
  """
91
- retry_strategy = self._retry
92
- if retry is not None or backoff is not None:
93
- retry_strategy = Retry(
94
- total=retry if retry is not None else self._retry.total,
95
- backoff_factor=(
96
- backoff
97
- if backoff is not None
98
- else self._retry.backoff_factor
99
- ),
100
- backoff_jitter=self._retry.backoff_jitter,
101
- allowed_methods=["POST"],
102
- )
103
- transport = RetryTransport(retry=retry_strategy)
123
+ if retry is not None:
124
+ self._validate_retry_attempts(retry)
125
+ if backoff is not None:
126
+ self._validate_backoff_factor(backoff)
127
+
128
+ retry_strategy = Retry(
129
+ total=retry if retry is not None else self._retry.total,
130
+ backoff_factor=(
131
+ backoff if backoff is not None else self._retry.backoff_factor
132
+ ),
133
+ backoff_jitter=self._retry.backoff_jitter,
134
+ allowed_methods=["POST"],
135
+ )
136
+ transport = RetryTransport(
137
+ transport=AsyncHTTPTransport(),
138
+ retry=retry_strategy,
139
+ )
104
140
  return AsyncClient(
105
141
  headers=headers,
106
- limits=self._limits,
107
- timeout=5.0,
142
+ timeout=self._timeout,
108
143
  transport=transport,
109
144
  )
110
145
 
146
+ @property
147
+ def is_shutdown(self) -> bool:
148
+ """Whether the pool has been explicitly shut down."""
149
+ return self._shutdown
150
+
151
+ async def shutdown(self) -> None:
152
+ """Close all clients and release their connections."""
153
+ self._shutdown = True
154
+ if self._client is not None and not self._client.is_closed:
155
+ await self._client.aclose()
156
+ self._client = None
157
+
111
158
  @staticmethod
112
159
  def _validate_retry_attempts(retry_attempts: int) -> None:
113
160
  if not isinstance(retry_attempts, int) or retry_attempts < 0:
@@ -123,6 +170,11 @@ class ConnectionPool:
123
170
  if not isinstance(backoff_jitter, (int, float)) or backoff_jitter < 0:
124
171
  raise ValueError("backoff_jitter must be a positive number")
125
172
 
173
+ @staticmethod
174
+ def _validate_timeout(timeout: float) -> None:
175
+ if not isinstance(timeout, (int, float)) or timeout <= 0:
176
+ raise ValueError("timeout must be a positive number")
177
+
126
178
  @property
127
179
  def limits(self) -> Limits:
128
180
  """
@@ -141,7 +193,8 @@ class ConnectionPool:
141
193
  f"keepalive_expiry={self._limits.keepalive_expiry}, "
142
194
  f"retry_attempts={self._retry.total}, "
143
195
  f"backoff_factor={self._retry.backoff_factor}, "
144
- f"backoff_jitter={self._retry.backoff_jitter})"
196
+ f"backoff_jitter={self._retry.backoff_jitter}, "
197
+ f"timeout={self._timeout})"
145
198
  )
146
199
 
147
200
  def __str__(self) -> str:
@@ -129,25 +129,37 @@ class SendgridAPI(BaseSendgridAPI):
129
129
  ) -> Response:
130
130
  """
131
131
  Make a Twilio SendGrid v3 API request with the request body generated
132
- by the Mail object
132
+ by the Mail object.
133
+
134
+ .. warning::
135
+ Calling this method repeatedly with per-call ``retry`` or
136
+ ``backoff`` overrides will open and close a new connection
137
+ each time, which can degrade throughput significantly.
138
+ For regular sends, configure the desired retry and backoff
139
+ on the ``ConnectionPool`` once at initialization.
133
140
 
134
141
  Args:
135
142
  email: The Twilio SendGrid v3 API request body generated
136
- by the Mail object or dict
143
+ by the Mail object or dict.
137
144
  retry: Override the number of retry attempts for this
138
- request. Uses the client default when not set.
145
+ request. Creates an ephemeral client.
146
+ Uses the pool default when not set.
139
147
  backoff: Override the backoff factor for this request.
140
- Uses the client default when not set.
148
+ Creates an ephemeral client.
149
+ Uses the pool default when not set.
141
150
 
142
151
  Returns:
143
- The Twilio SendGrid v3 API response
152
+ The Twilio SendGrid v3 API response.
144
153
  """
145
154
  self._check_session_closed()
146
155
  json_message = email.get()
147
156
 
148
157
  if retry is not None or backoff is not None:
149
- async with self._build_client(retry, backoff) as session:
158
+ session = self._build_client(retry, backoff)
159
+ try:
150
160
  return await self._send(session, json_message)
161
+ finally:
162
+ await session.aclose()
151
163
 
152
164
  return await self._send(self._session, json_message)
153
165
 
@@ -161,7 +173,7 @@ class SendgridAPI(BaseSendgridAPI):
161
173
  retry: Optional[int] = None,
162
174
  backoff: Optional[float] = None,
163
175
  ) -> AsyncClient:
164
- return self._pool._create_client(
176
+ return self._pool._create_ephemeral_client(
165
177
  self._headers, retry=retry, backoff=backoff
166
178
  )
167
179
 
@@ -169,13 +181,23 @@ class SendgridAPI(BaseSendgridAPI):
169
181
  """
170
182
  Check if the session is closed.
171
183
 
184
+ If the pool was explicitly shut down, raises
185
+ ``SessionClosedException``. Otherwise, transparently
186
+ rebuilds the underlying client.
187
+
172
188
  Raises:
173
- SessionClosedException: If the session is closed.
189
+ SessionClosedException: If the pool has been shut down.
174
190
  """
175
- if self._session.is_closed:
191
+ if not self._session.is_closed:
192
+ return
193
+
194
+ if self._pool.is_shutdown:
176
195
  logger.error("Session not initialized")
177
196
  raise SessionClosedException("Session not initialized")
178
197
 
198
+ logger.debug("Session closed unexpectedly, rebuilding client")
199
+ self._session = self._pool._create_client(self._headers)
200
+
179
201
  def __repr__(self) -> str:
180
202
  return (
181
203
  f"SendgridAPI("
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "sendgrid-async"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "SendGrid using an httpx client"
5
5
  license = "MIT"
6
6
  authors = ["Elyes Mahjoubi <elyesmahjoubi@gmail.com>"]
@@ -1,4 +0,0 @@
1
- from .sendgrid import SendgridAPI # noqa
2
-
3
- __version__ = "2.1.2"
4
- __all__ = ["SendgridAPI"]
File without changes