Skip to content

Commit d671096

Browse files
committed
Add Structured logging, and X-Request-ID support
1 parent d8a2b35 commit d671096

7 files changed

Lines changed: 128 additions & 17 deletions

File tree

‎postmark/clients/account_client.py‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import os
33
import sys
4+
import time
45
from typing import Any, Dict, List, Optional, Union
56

67
import httpx
@@ -93,7 +94,7 @@ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
9394
"""
9495
Make a request to the Postmark API using the configured account token.
9596
"""
96-
logger.debug(f"Making {method} request to {endpoint}")
97+
logger.debug("Making request", extra={"method": method, "endpoint": endpoint})
9798

9899
async for attempt in AsyncRetrying(
99100
retry=retry_if_exception_type(
@@ -105,32 +106,69 @@ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
105106
):
106107
with attempt:
107108
try:
109+
start = time.monotonic()
108110
response = await self._http_client.request(
109111
method, endpoint, **kwargs
110112
)
111113
response.raise_for_status()
112-
logger.debug(f"Request successful: {response.status_code}")
114+
duration_ms = round((time.monotonic() - start) * 1000)
115+
request_id = response.headers.get("X-Request-Id")
116+
logger.debug(
117+
"Request successful",
118+
extra={
119+
"method": method,
120+
"endpoint": endpoint,
121+
"status_code": response.status_code,
122+
"duration_ms": duration_ms,
123+
"request_id": request_id,
124+
},
125+
)
113126
return response
114127

115128
except httpx.TimeoutException as e:
116-
logger.error(f"Request timeout for {method} {endpoint}")
129+
duration_ms = round((time.monotonic() - start) * 1000)
130+
logger.error(
131+
"Request timed out",
132+
extra={
133+
"method": method,
134+
"endpoint": endpoint,
135+
"duration_ms": duration_ms,
136+
},
137+
)
117138
raise TimeoutException("Request timed out after 30 seconds") from e
118139

119140
except httpx.HTTPStatusError as e:
141+
duration_ms = round((time.monotonic() - start) * 1000)
142+
request_id = e.response.headers.get("X-Request-Id")
120143
message, error_code = parse_error_response(e.response)
121144
http_status = e.response.status_code
122145

123-
logger.error(f"API error {error_code or http_status}: {message}")
146+
logger.error(
147+
"API error",
148+
extra={
149+
"method": method,
150+
"endpoint": endpoint,
151+
"status_code": http_status,
152+
"error_code": error_code or http_status,
153+
"postmark_message": message,
154+
"duration_ms": duration_ms,
155+
"request_id": request_id,
156+
},
157+
)
124158

125159
exception_class = get_exception_class(error_code or 0, http_status)
126160
raise exception_class(
127161
message=message,
128162
error_code=error_code or 0,
129163
http_status=http_status,
164+
request_id=request_id,
130165
) from e
131166

132167
except httpx.RequestError as e:
133-
logger.error(f"Request failed: {e}")
168+
logger.error(
169+
"Request failed",
170+
extra={"method": method, "endpoint": endpoint, "error": str(e)},
171+
)
134172
raise PostmarkException(f"Request failed: {str(e)}") from e
135173

136174
raise AssertionError("The Postmark API is unreachable.")

‎postmark/clients/server_client.py‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import os
33
import sys
4+
import time
45
from typing import Any, Dict, List, Optional, Union
56

67
import httpx
@@ -104,7 +105,7 @@ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
104105
"""
105106
Make a request to the Postmark API using the configured token.
106107
"""
107-
logger.debug(f"Making {method} request to {endpoint}")
108+
logger.debug("Making request", extra={"method": method, "endpoint": endpoint})
108109

109110
async for attempt in AsyncRetrying(
110111
retry=retry_if_exception_type(
@@ -116,23 +117,56 @@ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
116117
):
117118
with attempt:
118119
try:
120+
start = time.monotonic()
119121
response = await self._http_client.request(
120122
method, endpoint, **kwargs
121123
)
122124
response.raise_for_status()
123-
logger.debug(f"Request successful: {response.status_code}")
125+
duration_ms = round((time.monotonic() - start) * 1000)
126+
request_id = response.headers.get("X-Request-Id")
127+
logger.debug(
128+
"Request successful",
129+
extra={
130+
"method": method,
131+
"endpoint": endpoint,
132+
"status_code": response.status_code,
133+
"duration_ms": duration_ms,
134+
"request_id": request_id,
135+
},
136+
)
124137
return response
125138

126139
except httpx.TimeoutException as e:
127-
logger.error(f"Request timeout for {method} {endpoint}")
140+
duration_ms = round((time.monotonic() - start) * 1000)
141+
logger.error(
142+
"Request timed out",
143+
extra={
144+
"method": method,
145+
"endpoint": endpoint,
146+
"duration_ms": duration_ms,
147+
},
148+
)
128149
raise TimeoutException("Request timed out after 30 seconds") from e
129150

130151
except httpx.HTTPStatusError as e:
152+
duration_ms = round((time.monotonic() - start) * 1000)
153+
request_id = e.response.headers.get("X-Request-Id")
131154
# Parse Postmark error response
132155
message, error_code = parse_error_response(e.response)
133156
http_status = e.response.status_code
134157

135-
logger.error(f"API error {error_code or http_status}: {message}")
158+
logger.error(
159+
"API error",
160+
extra={
161+
"method": method,
162+
"endpoint": endpoint,
163+
"status_code": http_status,
164+
"error_code": error_code or http_status,
165+
"postmark_message": message,
166+
"duration_ms": duration_ms,
167+
"request_id": request_id,
168+
},
169+
)
136170

137171
# Get exception class
138172
exception_class = get_exception_class(error_code or 0, http_status)
@@ -142,10 +176,14 @@ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
142176
message=message,
143177
error_code=error_code or 0,
144178
http_status=http_status,
179+
request_id=request_id,
145180
) from e
146181

147182
except httpx.RequestError as e:
148-
logger.error(f"Request failed: {e}")
183+
logger.error(
184+
"Request failed",
185+
extra={"method": method, "endpoint": endpoint, "error": str(e)},
186+
)
149187
raise PostmarkException(f"Request failed: {str(e)}") from e
150188

151189
raise AssertionError("The Postmark API is unreachable.")

‎postmark/exceptions.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ class PostmarkAPIException(PostmarkException):
4646
Always carries both a Postmark error_code and an http_status.
4747
"""
4848

49-
def __init__(self, message: str, error_code: int, http_status: int):
49+
def __init__(
50+
self,
51+
message: str,
52+
error_code: int,
53+
http_status: int,
54+
request_id: Optional[str] = None,
55+
):
5056
super().__init__(message, error_code, http_status)
57+
self.request_id = request_id
5158

5259
def __str__(self):
53-
return f"[{self.error_code}] {self.message} (HTTP {self.http_status})"
60+
base = f"[{self.error_code}] {self.message} (HTTP {self.http_status})"
61+
return f"{base} [request_id={self.request_id}]" if self.request_id else base
5462

5563

5664
class InvalidAPIKeyException(PostmarkAPIException):
@@ -62,8 +70,14 @@ class InvalidAPIKeyException(PostmarkAPIException):
6270
class InactiveRecipientException(PostmarkAPIException):
6371
"""406 - Inactive recipient."""
6472

65-
def __init__(self, message: str, error_code: int, http_status: int):
66-
super().__init__(message, error_code, http_status)
73+
def __init__(
74+
self,
75+
message: str,
76+
error_code: int,
77+
http_status: int,
78+
request_id: Optional[str] = None,
79+
):
80+
super().__init__(message, error_code, http_status, request_id)
6781
match = re.search(r"Found inactive addresses: ([^.]+)", message)
6882
self.inactive_recipients: list[str] = (
6983
[addr.strip() for addr in match.group(1).split(",")] if match else []

‎postmark/models/outbound/manager.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async def send(self, message: Union[Email, Dict[str, Any]]) -> SendResponse:
7979
"""Send a single email."""
8080
email_payload = _parse_email(message)
8181

82-
logger.debug(f"Sending email to {email_payload.to}")
82+
logger.debug("Sending email", extra={"recipient": email_payload.to})
8383
response = await self.client.post(
8484
"/email",
8585
json=email_payload.model_dump(by_alias=True, exclude_none=True),
@@ -137,7 +137,9 @@ async def send_bulk(
137137
"Bulk email must include at least one recipient in messages"
138138
)
139139

140-
logger.debug(f"Sending bulk email to {len(bulk_payload.messages)} recipients")
140+
logger.debug(
141+
"Sending bulk email", extra={"recipient_count": len(bulk_payload.messages)}
142+
)
141143
response = await self.client.post(
142144
"/email/bulk",
143145
json=bulk_payload.model_dump(by_alias=True, exclude_none=True),
@@ -160,7 +162,7 @@ async def send_with_template(
160162
) -> SendResponse:
161163
"""Send an email using a template."""
162164
email = _parse_template_email(message)
163-
logger.debug(f"Sending template email to {email.to}")
165+
logger.debug("Sending template email", extra={"recipient": email.to})
164166
response = await self.client.post(
165167
"/email/withTemplate",
166168
json=email.model_dump(by_alias=True, exclude_none=True),

‎tests/conftest.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def make_response(data: dict | list) -> Mock:
2525
response = Mock(spec=Response)
2626
response.json.return_value = data
2727
response.raise_for_status = Mock()
28+
response.headers = {}
2829
return response
2930

3031

‎tests/test_account_client.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def mock_ok_response(self):
3232
response = Mock(spec=Response)
3333
response.raise_for_status = Mock()
3434
response.status_code = 200
35+
response.headers = {}
3536
return response
3637

3738
# -------------------------------------------------------------------------
@@ -135,6 +136,7 @@ async def test_async_context_manager(self):
135136
async def test_401_raises_invalid_api_key(self, client):
136137
mock_response = Mock(spec=Response)
137138
mock_response.status_code = 401
139+
mock_response.headers = {}
138140
mock_response.json.return_value = {
139141
"ErrorCode": 10,
140142
"Message": "Invalid API key",
@@ -157,6 +159,7 @@ async def test_401_raises_invalid_api_key(self, client):
157159
async def test_422_raises_validation_exception(self, client):
158160
mock_response = Mock(spec=Response)
159161
mock_response.status_code = 422
162+
mock_response.headers = {}
160163
mock_response.json.return_value = {
161164
"ErrorCode": 300,
162165
"Message": "Invalid request body",
@@ -200,13 +203,15 @@ async def test_retries_on_rate_limit(self):
200203

201204
failing_resp = Mock(spec=Response)
202205
failing_resp.status_code = 429
206+
failing_resp.headers = {}
203207
failing_resp.json.return_value = {"ErrorCode": 429, "Message": "Rate limit"}
204208
failing_resp.raise_for_status.side_effect = HTTPStatusError(
205209
"429", request=Mock(), response=failing_resp
206210
)
207211
ok_resp = Mock(spec=Response)
208212
ok_resp.raise_for_status = Mock()
209213
ok_resp.status_code = 200
214+
ok_resp.headers = {}
210215

211216
with patch("asyncio.sleep", new_callable=AsyncMock):
212217
with patch.object(
@@ -226,6 +231,7 @@ async def test_retries_exhausted_reraises(self):
226231

227232
mock_response = Mock(spec=Response)
228233
mock_response.status_code = 500
234+
mock_response.headers = {}
229235
mock_response.json.return_value = {"ErrorCode": 500, "Message": "Server error"}
230236
mock_response.raise_for_status.side_effect = HTTPStatusError(
231237
"500", request=Mock(), response=mock_response
@@ -249,6 +255,7 @@ async def test_no_retry_on_validation_error(self):
249255

250256
mock_response = Mock(spec=Response)
251257
mock_response.status_code = 422
258+
mock_response.headers = {}
252259
mock_response.json.return_value = {
253260
"ErrorCode": 300,
254261
"Message": "Validation error",
@@ -271,6 +278,7 @@ async def test_retries_disabled_with_zero(self):
271278

272279
mock_response = Mock(spec=Response)
273280
mock_response.status_code = 500
281+
mock_response.headers = {}
274282
mock_response.json.return_value = {"ErrorCode": 500, "Message": "Server error"}
275283
mock_response.raise_for_status.side_effect = HTTPStatusError(
276284
"500", request=Mock(), response=mock_response

0 commit comments

Comments
 (0)