021-python-clients 0.1.0__py3-none-any.whl
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.
- 021_python_clients-0.1.0.dist-info/METADATA +327 -0
- 021_python_clients-0.1.0.dist-info/RECORD +21 -0
- 021_python_clients-0.1.0.dist-info/WHEEL +4 -0
- 021_python_clients-0.1.0.dist-info/licenses/LICENSE +202 -0
- clients_021/__init__.py +22 -0
- clients_021/_aws.py +55 -0
- clients_021/_observe.py +106 -0
- clients_021/email/__init__.py +16 -0
- clients_021/email/_message.py +77 -0
- clients_021/email/resend.py +143 -0
- clients_021/email/ses.py +119 -0
- clients_021/errors.py +20 -0
- clients_021/http.py +131 -0
- clients_021/httpx2.py +93 -0
- clients_021/kms.py +178 -0
- clients_021/py.typed +0 -0
- clients_021/queue/__init__.py +6 -0
- clients_021/queue/qstash.py +219 -0
- clients_021/queue/sqs.py +177 -0
- clients_021/redis.py +124 -0
- clients_021/s3.py +164 -0
clients_021/_observe.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""One CLIENT span and two instruments per call, under a stable dependency name.
|
|
2
|
+
|
|
3
|
+
The `db` family reuses the instruments 021-python-observability already creates for SQL
|
|
4
|
+
(`db.client.operation.*`), so Redis and Postgres share dashboards. `messaging` (SQS, email),
|
|
5
|
+
`storage` (S3) and `crypto` (KMS) get their own pair on the same meter. Attributes are
|
|
6
|
+
bounded: dependency, operation, status and error.type; never keys, URLs, bodies or ids.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import AsyncGenerator, Callable
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from typing import Literal
|
|
16
|
+
from weakref import WeakKeyDictionary
|
|
17
|
+
|
|
18
|
+
from observability_021 import Observability, get_logger
|
|
19
|
+
from opentelemetry.metrics import Counter, Histogram
|
|
20
|
+
from opentelemetry.trace import SpanKind, StatusCode
|
|
21
|
+
|
|
22
|
+
from clients_021.errors import DependencyUnavailable
|
|
23
|
+
|
|
24
|
+
Family = Literal["db", "messaging", "storage", "crypto"]
|
|
25
|
+
# Returns the reason when an exception means "the dependency failed", None for a bug.
|
|
26
|
+
Classifier = Callable[[BaseException], str | None]
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
_UNITS = {
|
|
30
|
+
"db": "{operation}",
|
|
31
|
+
"messaging": "{operation}",
|
|
32
|
+
"storage": "{operation}",
|
|
33
|
+
"crypto": "{operation}",
|
|
34
|
+
}
|
|
35
|
+
_cache: WeakKeyDictionary[Observability, dict[Family, tuple[Counter, Histogram]]] = (
|
|
36
|
+
WeakKeyDictionary()
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def instruments(obs: Observability, family: Family) -> tuple[Counter, Histogram]:
|
|
41
|
+
per_obs = _cache.setdefault(obs, {})
|
|
42
|
+
pair = per_obs.get(family)
|
|
43
|
+
if pair is None:
|
|
44
|
+
if family == "db":
|
|
45
|
+
pair = (obs.db_requests, obs.db_duration)
|
|
46
|
+
else:
|
|
47
|
+
meter = obs.meter
|
|
48
|
+
pair = (
|
|
49
|
+
meter.create_counter(f"{family}.client.operation.count", unit=_UNITS[family]),
|
|
50
|
+
meter.create_histogram(f"{family}.client.operation.duration", unit="s"),
|
|
51
|
+
)
|
|
52
|
+
per_obs[family] = pair
|
|
53
|
+
return pair
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@asynccontextmanager
|
|
57
|
+
async def observed(
|
|
58
|
+
obs: Observability,
|
|
59
|
+
*,
|
|
60
|
+
family: Family,
|
|
61
|
+
system: dict[str, str],
|
|
62
|
+
dependency: str,
|
|
63
|
+
operation: str,
|
|
64
|
+
classify: Classifier,
|
|
65
|
+
) -> AsyncGenerator[None]:
|
|
66
|
+
"""Wraps one call. Dependency failures leave as DependencyUnavailable; bugs as they are."""
|
|
67
|
+
attributes: dict[str, str] = {"dependency": dependency, "operation": operation}
|
|
68
|
+
count, duration = instruments(obs, family)
|
|
69
|
+
started = time.perf_counter()
|
|
70
|
+
tracer = obs.tracer
|
|
71
|
+
with tracer.start_as_current_span(
|
|
72
|
+
f"{dependency} {operation}",
|
|
73
|
+
kind=SpanKind.CLIENT,
|
|
74
|
+
attributes={**system, **attributes},
|
|
75
|
+
record_exception=False,
|
|
76
|
+
set_status_on_exception=False,
|
|
77
|
+
) as span:
|
|
78
|
+
try:
|
|
79
|
+
yield
|
|
80
|
+
except asyncio.CancelledError:
|
|
81
|
+
attributes["status"] = "error"
|
|
82
|
+
attributes["error.type"] = "cancelled"
|
|
83
|
+
span.set_status(StatusCode.ERROR)
|
|
84
|
+
span.set_attributes(attributes)
|
|
85
|
+
raise
|
|
86
|
+
except BaseException as error:
|
|
87
|
+
reason = classify(error)
|
|
88
|
+
attributes["status"] = "error"
|
|
89
|
+
attributes["error.type"] = reason or type(error).__name__
|
|
90
|
+
span.set_status(StatusCode.ERROR)
|
|
91
|
+
span.set_attributes(attributes)
|
|
92
|
+
logger.warning(
|
|
93
|
+
"dependency.call_failed",
|
|
94
|
+
dependency=dependency,
|
|
95
|
+
operation=operation,
|
|
96
|
+
error_type=attributes["error.type"],
|
|
97
|
+
)
|
|
98
|
+
if reason is None:
|
|
99
|
+
raise
|
|
100
|
+
raise DependencyUnavailable(dependency, operation, reason) from error
|
|
101
|
+
else:
|
|
102
|
+
attributes["status"] = "ok"
|
|
103
|
+
span.set_attributes(attributes)
|
|
104
|
+
finally:
|
|
105
|
+
count.add(1, attributes)
|
|
106
|
+
duration.record(time.perf_counter() - started, attributes)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Transactional email behind one protocol: Resend (no extra) or SES (`clients_021.email.ses`).
|
|
2
|
+
|
|
3
|
+
from clients_021.email import EmailMessage, EmailSender, ResendSender
|
|
4
|
+
|
|
5
|
+
email: EmailSender = ResendSender(obs, settings.resend_api_key)
|
|
6
|
+
await email.send(EmailMessage(sender=..., to=..., subject=..., text=...))
|
|
7
|
+
|
|
8
|
+
Every sender records `messaging.client.operation.*` with `operation=send`, raises
|
|
9
|
+
DependencyUnavailable when retrying later may work and EmailRejected when the provider refused
|
|
10
|
+
the message or the credentials. Addresses, subjects and bodies are never recorded.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from clients_021.email._message import EmailMessage, EmailRejected, EmailSender
|
|
14
|
+
from clients_021.email.resend import ResendSender
|
|
15
|
+
|
|
16
|
+
__all__ = ["EmailMessage", "EmailRejected", "EmailSender", "ResendSender"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""What every email sender shares: the message, the sender protocol and the rejection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
# Resend and SES accept the same tag syntax: ASCII letters, digits, `_` and `-`, ≤ 256 chars.
|
|
11
|
+
_TAG = re.compile(r"[A-Za-z0-9_-]{1,256}")
|
|
12
|
+
# A provider error code is a short identifier; anything else is reported as `unknown`.
|
|
13
|
+
_CODE = re.compile(r"[A-Za-z][A-Za-z0-9_.-]{0,63}")
|
|
14
|
+
MAX_IDEMPOTENCY_KEY = 256
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class EmailMessage:
|
|
19
|
+
"""One email. `to` and `reply_to` accept one address or several; bodies stay out of repr."""
|
|
20
|
+
|
|
21
|
+
sender: str
|
|
22
|
+
to: Sequence[str]
|
|
23
|
+
subject: str
|
|
24
|
+
text: str | None = field(default=None, repr=False)
|
|
25
|
+
html: str | None = field(default=None, repr=False)
|
|
26
|
+
reply_to: Sequence[str] = ()
|
|
27
|
+
# Provider tags for analytics and webhooks: bounded names and values, never personal data.
|
|
28
|
+
tags: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not self.sender:
|
|
32
|
+
raise ValueError("sender is required")
|
|
33
|
+
to = _addresses(self.to, "to")
|
|
34
|
+
if not to:
|
|
35
|
+
raise ValueError("to needs at least one recipient")
|
|
36
|
+
if self.text is None and self.html is None:
|
|
37
|
+
raise ValueError("text or html is required")
|
|
38
|
+
if not all(_TAG.fullmatch(k) and _TAG.fullmatch(v) for k, v in self.tags.items()):
|
|
39
|
+
raise ValueError("tag names and values must be 1-256 ASCII letters, digits, _ or -")
|
|
40
|
+
object.__setattr__(self, "to", to)
|
|
41
|
+
object.__setattr__(self, "reply_to", _addresses(self.reply_to, "reply_to"))
|
|
42
|
+
object.__setattr__(self, "tags", dict(self.tags))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _addresses(value: str | Sequence[str], name: str) -> tuple[str, ...]:
|
|
46
|
+
addresses = (value,) if isinstance(value, str) else tuple(value)
|
|
47
|
+
if not all(addresses):
|
|
48
|
+
raise ValueError(f"{name} contains an empty address")
|
|
49
|
+
return addresses
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_idempotency_key(key: str | None) -> str | None:
|
|
53
|
+
if key is not None and not 1 <= len(key) <= MAX_IDEMPOTENCY_KEY:
|
|
54
|
+
raise ValueError(f"idempotency_key must be 1-{MAX_IDEMPOTENCY_KEY} characters")
|
|
55
|
+
return key
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EmailSender(Protocol):
|
|
59
|
+
async def send(self, message: EmailMessage, *, idempotency_key: str | None = None) -> str:
|
|
60
|
+
"""Hands the message to the provider and returns the provider's message id."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EmailRejected(Exception): # noqa: N818 - the name says what the caller handles
|
|
65
|
+
"""The provider refused the message or the credentials; sending it again will not help.
|
|
66
|
+
|
|
67
|
+
`status` is the HTTP status and `code` the provider's error name (`validation_error`,
|
|
68
|
+
`MessageRejected`, ...). Like DependencyUnavailable it never carries addresses, subjects,
|
|
69
|
+
bodies or the provider's message, so it is safe to log; the chained exception is not.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, dependency: str, status: int, code: str) -> None:
|
|
73
|
+
code = code if _CODE.fullmatch(code) else "unknown"
|
|
74
|
+
super().__init__(f"{dependency} send: rejected ({status} {code})")
|
|
75
|
+
self.dependency = dependency
|
|
76
|
+
self.status = status
|
|
77
|
+
self.code = code
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Resend, observed: one `send` per message over Resend's HTTP API.
|
|
2
|
+
|
|
3
|
+
from clients_021.email import EmailMessage, ResendSender
|
|
4
|
+
|
|
5
|
+
email = ResendSender(obs, settings.resend_api_key)
|
|
6
|
+
message_id = await email.send(
|
|
7
|
+
EmailMessage(sender="021 <hola@from021.io>", to="ana@example.com",
|
|
8
|
+
subject="Your code", text="..."),
|
|
9
|
+
idempotency_key=f"login-code:{attempt_id}",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
Needs no extra. Calls record `messaging.client.operation.*` (`operation=send`) and a
|
|
13
|
+
`<dependency> send` span with `messaging.system=resend`; the HTTP request below it records
|
|
14
|
+
`http.client.request.*` through 021-python-observability's transport. No trace headers are
|
|
15
|
+
sent to Resend. The request is sent once: with an idempotency key, retrying after
|
|
16
|
+
DependencyUnavailable within Resend's 24 hours cannot send the email twice.
|
|
17
|
+
429, 5xx, timeouts and connection failures raise DependencyUnavailable; any other non-2xx
|
|
18
|
+
raises EmailRejected with the status and Resend's error name.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from typing import Any, cast
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from observability_021 import Observability
|
|
28
|
+
from observability_021.config import identifier
|
|
29
|
+
|
|
30
|
+
from clients_021 import __version__
|
|
31
|
+
from clients_021._observe import observed
|
|
32
|
+
from clients_021.email._message import EmailMessage, EmailRejected, check_idempotency_key
|
|
33
|
+
from clients_021.errors import DependencyUnavailable
|
|
34
|
+
from clients_021.http import HttpClient
|
|
35
|
+
|
|
36
|
+
URL = "https://api.resend.com/emails"
|
|
37
|
+
SYSTEM = {"messaging.system": "resend"}
|
|
38
|
+
# Added by the observed transport; Resend is a third-party API, so they never leave.
|
|
39
|
+
_TRACE_HEADERS = ("traceparent", "tracestate", "x-request-id")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Status(Exception):
|
|
43
|
+
"""A status that means Resend let us down; observed() turns it into DependencyUnavailable."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, status: int) -> None:
|
|
46
|
+
super().__init__(f"status {status}")
|
|
47
|
+
self.status = status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _classify(error: BaseException) -> str | None:
|
|
51
|
+
if isinstance(error, DependencyUnavailable):
|
|
52
|
+
return error.reason
|
|
53
|
+
if isinstance(error, _Status):
|
|
54
|
+
return "throttled" if error.status == 429 else f"status_{error.status}"
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _error_name(body: bytes) -> str:
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(body)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return "unknown"
|
|
63
|
+
name = cast(dict[str, Any], data).get("name") if isinstance(data, dict) else None
|
|
64
|
+
return name if isinstance(name, str) else "unknown"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _WithoutTraceHeaders(httpx.AsyncBaseTransport):
|
|
68
|
+
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
|
|
69
|
+
self._transport = transport
|
|
70
|
+
|
|
71
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
72
|
+
for name in _TRACE_HEADERS:
|
|
73
|
+
request.headers.pop(name, None)
|
|
74
|
+
return await self._transport.handle_async_request(request)
|
|
75
|
+
|
|
76
|
+
async def aclose(self) -> None:
|
|
77
|
+
await self._transport.aclose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ResendSender:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
obs: Observability,
|
|
84
|
+
api_key: str,
|
|
85
|
+
*,
|
|
86
|
+
dependency: str = "resend",
|
|
87
|
+
timeout_seconds: float = 10.0,
|
|
88
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
if not api_key:
|
|
91
|
+
raise ValueError("api_key is required")
|
|
92
|
+
self._obs = obs
|
|
93
|
+
self.dependency = identifier(dependency)
|
|
94
|
+
self._headers = {
|
|
95
|
+
"Authorization": f"Bearer {api_key}",
|
|
96
|
+
# Resend rejects requests without a User-Agent.
|
|
97
|
+
"User-Agent": f"021-python-clients/{__version__}",
|
|
98
|
+
}
|
|
99
|
+
inner = transport if transport is not None else httpx.AsyncHTTPTransport()
|
|
100
|
+
self._http = HttpClient(
|
|
101
|
+
obs, timeout_seconds=timeout_seconds, retries=0, transport=_WithoutTraceHeaders(inner)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def send(self, message: EmailMessage, *, idempotency_key: str | None = None) -> str:
|
|
105
|
+
"""Returns Resend's email id."""
|
|
106
|
+
headers = dict(self._headers)
|
|
107
|
+
key = check_idempotency_key(idempotency_key)
|
|
108
|
+
if key is not None:
|
|
109
|
+
headers["Idempotency-Key"] = key
|
|
110
|
+
body: dict[str, Any] = {
|
|
111
|
+
"from": message.sender,
|
|
112
|
+
"to": list(message.to),
|
|
113
|
+
"subject": message.subject,
|
|
114
|
+
}
|
|
115
|
+
if message.text is not None:
|
|
116
|
+
body["text"] = message.text
|
|
117
|
+
if message.html is not None:
|
|
118
|
+
body["html"] = message.html
|
|
119
|
+
if message.reply_to:
|
|
120
|
+
body["reply_to"] = list(message.reply_to)
|
|
121
|
+
if message.tags:
|
|
122
|
+
body["tags"] = [{"name": name, "value": value} for name, value in message.tags.items()]
|
|
123
|
+
async with observed(
|
|
124
|
+
self._obs,
|
|
125
|
+
family="messaging",
|
|
126
|
+
system=SYSTEM,
|
|
127
|
+
dependency=self.dependency,
|
|
128
|
+
operation="send",
|
|
129
|
+
classify=_classify,
|
|
130
|
+
):
|
|
131
|
+
response = await self._http.request(
|
|
132
|
+
"POST", URL, dependency=self.dependency, headers=headers, json=body
|
|
133
|
+
)
|
|
134
|
+
status = response.status_code
|
|
135
|
+
if status == 429 or status >= 500:
|
|
136
|
+
raise _Status(status)
|
|
137
|
+
if not 200 <= status < 300:
|
|
138
|
+
raise EmailRejected(self.dependency, status, _error_name(response.body))
|
|
139
|
+
email_id: str = response.json()["id"]
|
|
140
|
+
return email_id
|
|
141
|
+
|
|
142
|
+
async def aclose(self) -> None:
|
|
143
|
+
await self._http.aclose()
|
clients_021/email/ses.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Amazon SES (API v2), observed: one `send` per message.
|
|
2
|
+
|
|
3
|
+
from clients_021.email import EmailMessage
|
|
4
|
+
from clients_021.email.ses import SesSender
|
|
5
|
+
|
|
6
|
+
email = SesSender.create(obs, region="us-east-1", configuration_set="transactional")
|
|
7
|
+
message_id = await email.send(EmailMessage(sender=..., to=..., subject=..., text=...))
|
|
8
|
+
|
|
9
|
+
Needs the `aws` extra. Calls record `messaging.client.operation.*` (`operation=send`) and a
|
|
10
|
+
`<dependency> send` span with `messaging.system=aws_ses`. Timeouts, connection failures,
|
|
11
|
+
throttling (`TooManyRequestsException`) and 5xx raise DependencyUnavailable. Any other SES
|
|
12
|
+
error (`MessageRejected`, an unverified sender, access denied, a missing configuration set)
|
|
13
|
+
raises EmailRejected with the status and SES error code, chained to the botocore error, so a
|
|
14
|
+
service handles one rejection type whichever sender it is configured with.
|
|
15
|
+
SES has no idempotency key: the argument is validated like Resend's and then ignored, so
|
|
16
|
+
deduplicate in the caller when a retry must not send twice.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
import boto3
|
|
25
|
+
from botocore.exceptions import ClientError
|
|
26
|
+
from observability_021 import Observability
|
|
27
|
+
from observability_021.config import identifier
|
|
28
|
+
|
|
29
|
+
from clients_021 import _aws
|
|
30
|
+
from clients_021._observe import observed
|
|
31
|
+
from clients_021.email._message import EmailMessage, EmailRejected, check_idempotency_key
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from mypy_boto3_sesv2 import SESV2Client
|
|
35
|
+
else:
|
|
36
|
+
SESV2Client = Any
|
|
37
|
+
|
|
38
|
+
SYSTEM = {"messaging.system": "aws_ses"}
|
|
39
|
+
_UTF8 = "UTF-8"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SesSender:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
obs: Observability,
|
|
46
|
+
client: SESV2Client,
|
|
47
|
+
*,
|
|
48
|
+
configuration_set: str | None = None,
|
|
49
|
+
dependency: str = "ses",
|
|
50
|
+
) -> None:
|
|
51
|
+
self._obs = obs
|
|
52
|
+
self._client = client
|
|
53
|
+
self._configuration_set = configuration_set
|
|
54
|
+
self.dependency = identifier(dependency)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def create(
|
|
58
|
+
cls,
|
|
59
|
+
obs: Observability,
|
|
60
|
+
*,
|
|
61
|
+
region: str | None = None,
|
|
62
|
+
endpoint_url: str | None = None,
|
|
63
|
+
configuration_set: str | None = None,
|
|
64
|
+
dependency: str = "ses",
|
|
65
|
+
timeout_seconds: float = 10.0,
|
|
66
|
+
) -> SesSender:
|
|
67
|
+
client: SESV2Client = boto3.client( # pyright: ignore[reportUnknownMemberType]
|
|
68
|
+
"sesv2",
|
|
69
|
+
region_name=region,
|
|
70
|
+
endpoint_url=endpoint_url,
|
|
71
|
+
config=_aws.config(
|
|
72
|
+
connect_seconds=min(timeout_seconds, 3.0), read_seconds=timeout_seconds
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
return cls(obs, client, configuration_set=configuration_set, dependency=dependency)
|
|
76
|
+
|
|
77
|
+
async def send(self, message: EmailMessage, *, idempotency_key: str | None = None) -> str:
|
|
78
|
+
"""Returns the SES message id. `idempotency_key` is accepted for the protocol only."""
|
|
79
|
+
check_idempotency_key(idempotency_key)
|
|
80
|
+
body: dict[str, Any] = {}
|
|
81
|
+
if message.text is not None:
|
|
82
|
+
body["Text"] = {"Data": message.text, "Charset": _UTF8}
|
|
83
|
+
if message.html is not None:
|
|
84
|
+
body["Html"] = {"Data": message.html, "Charset": _UTF8}
|
|
85
|
+
request: dict[str, Any] = {
|
|
86
|
+
"FromEmailAddress": message.sender,
|
|
87
|
+
"Destination": {"ToAddresses": list(message.to)},
|
|
88
|
+
"Content": {
|
|
89
|
+
"Simple": {"Subject": {"Data": message.subject, "Charset": _UTF8}, "Body": body}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
if message.reply_to:
|
|
93
|
+
request["ReplyToAddresses"] = list(message.reply_to)
|
|
94
|
+
if message.tags:
|
|
95
|
+
request["EmailTags"] = [
|
|
96
|
+
{"Name": name, "Value": value} for name, value in message.tags.items()
|
|
97
|
+
]
|
|
98
|
+
if self._configuration_set is not None:
|
|
99
|
+
request["ConfigurationSetName"] = self._configuration_set
|
|
100
|
+
async with observed(
|
|
101
|
+
self._obs,
|
|
102
|
+
family="messaging",
|
|
103
|
+
system=SYSTEM,
|
|
104
|
+
dependency=self.dependency,
|
|
105
|
+
operation="send",
|
|
106
|
+
classify=_aws.classify,
|
|
107
|
+
):
|
|
108
|
+
try:
|
|
109
|
+
response = await asyncio.to_thread(self._client.send_email, **request)
|
|
110
|
+
except ClientError as error:
|
|
111
|
+
if _aws.classify(error) is not None:
|
|
112
|
+
raise
|
|
113
|
+
status = int(error.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 0))
|
|
114
|
+
raise EmailRejected(self.dependency, status, _aws.error_code(error)) from error
|
|
115
|
+
return response["MessageId"]
|
|
116
|
+
|
|
117
|
+
async def aclose(self) -> None:
|
|
118
|
+
"""Release the underlying SDK connection pools after in-flight calls finish."""
|
|
119
|
+
await asyncio.to_thread(self._client.close)
|
clients_021/errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The one failure type every client raises when the service it calls lets it down."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DependencyUnavailable(Exception): # noqa: N818 - the name says what the caller handles
|
|
7
|
+
"""The dependency timed out, refused the connection, throttled or failed (5xx).
|
|
8
|
+
|
|
9
|
+
Retrying later may work. It never carries keys, payloads, URLs or provider messages, so
|
|
10
|
+
its message and fields are safe to log and to map to a 503. The chained provider
|
|
11
|
+
exception is not sanitized and must not be logged. Errors that mean the request itself is
|
|
12
|
+
wrong (access denied, a missing bucket, a bad command) are not wrapped: they are bugs
|
|
13
|
+
or misconfiguration, and keep their original type.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, dependency: str, operation: str, reason: str) -> None:
|
|
17
|
+
super().__init__(f"{dependency} {operation}: {reason}")
|
|
18
|
+
self.dependency = dependency
|
|
19
|
+
self.operation = operation
|
|
20
|
+
self.reason = reason
|
clients_021/http.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Outbound HTTP: one httpx client per dependency, mandatory timeouts and bounded retries.
|
|
2
|
+
|
|
3
|
+
from clients_021.http import HttpClient
|
|
4
|
+
|
|
5
|
+
http = HttpClient(obs, timeout_seconds=10)
|
|
6
|
+
response = await http.request("GET", url, dependency="identity-provider")
|
|
7
|
+
|
|
8
|
+
Every call goes through 021-python-observability's transport: `http.client.request.*`
|
|
9
|
+
under the `dependency` name, a CLIENT span, and `traceparent` / `x-request-id` sent along.
|
|
10
|
+
Methods in IDEMPOTENT_METHODS are retried on transport errors and 502/503/504 with
|
|
11
|
+
backoff; POST and PATCH are sent once (send an Idempotency-Key and retry in the caller if
|
|
12
|
+
the other side supports it). A transport failure after the last attempt raises
|
|
13
|
+
DependencyUnavailable; any HTTP status is returned to the caller to judge.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json as jsonlib
|
|
20
|
+
import math
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from observability_021 import Observability
|
|
27
|
+
|
|
28
|
+
from clients_021.errors import DependencyUnavailable
|
|
29
|
+
|
|
30
|
+
HttpMethod = Literal["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"]
|
|
31
|
+
# Safe to send twice (RFC 9110, section 9.2.2): the only methods retried.
|
|
32
|
+
IDEMPOTENT_METHODS: frozenset[HttpMethod] = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
33
|
+
RETRY_STATUS = frozenset({502, 503, 504})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class HttpResponse:
|
|
38
|
+
status_code: int
|
|
39
|
+
headers: Mapping[str, str]
|
|
40
|
+
body: bytes
|
|
41
|
+
|
|
42
|
+
def json(self) -> Any:
|
|
43
|
+
return jsonlib.loads(self.body)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HttpClient:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
obs: Observability,
|
|
50
|
+
*,
|
|
51
|
+
timeout_seconds: float,
|
|
52
|
+
retries: int = 2,
|
|
53
|
+
backoff_seconds: float = 0.2,
|
|
54
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
57
|
+
raise ValueError("timeout_seconds must be finite and greater than 0")
|
|
58
|
+
if type(retries) is not int or retries < 0:
|
|
59
|
+
raise ValueError("retries must be a non-negative integer")
|
|
60
|
+
if not math.isfinite(backoff_seconds) or backoff_seconds < 0:
|
|
61
|
+
raise ValueError("backoff_seconds must be finite and non-negative")
|
|
62
|
+
self._obs = obs
|
|
63
|
+
self._timeout = httpx.Timeout(timeout_seconds, connect=min(timeout_seconds, 3.0))
|
|
64
|
+
self._retries = retries
|
|
65
|
+
self._backoff = backoff_seconds
|
|
66
|
+
self._transport = transport
|
|
67
|
+
self._clients: dict[str, httpx.AsyncClient] = {}
|
|
68
|
+
|
|
69
|
+
def _client(self, dependency: str) -> httpx.AsyncClient:
|
|
70
|
+
# One client per dependency, so every call is measured under a fixed name.
|
|
71
|
+
client = self._clients.get(dependency)
|
|
72
|
+
if client is None:
|
|
73
|
+
client = httpx.AsyncClient(
|
|
74
|
+
timeout=self._timeout,
|
|
75
|
+
follow_redirects=False,
|
|
76
|
+
transport=self._obs.async_httpx_transport(
|
|
77
|
+
dependency=dependency, transport=self._transport
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
self._clients[dependency] = client
|
|
81
|
+
return client
|
|
82
|
+
|
|
83
|
+
async def request(
|
|
84
|
+
self,
|
|
85
|
+
method: HttpMethod,
|
|
86
|
+
url: str,
|
|
87
|
+
*,
|
|
88
|
+
dependency: str,
|
|
89
|
+
headers: Mapping[str, str] | None = None,
|
|
90
|
+
json: Any = None,
|
|
91
|
+
form: Mapping[str, str] | None = None,
|
|
92
|
+
content: bytes | None = None,
|
|
93
|
+
) -> HttpResponse:
|
|
94
|
+
if sum(value is not None for value in (json, form, content)) > 1:
|
|
95
|
+
raise ValueError("provide only one of json, form or content")
|
|
96
|
+
attempts = 1 + (self._retries if method in IDEMPOTENT_METHODS else 0)
|
|
97
|
+
for attempt in range(attempts):
|
|
98
|
+
last = attempt == attempts - 1
|
|
99
|
+
try:
|
|
100
|
+
response = await self._client(dependency).request(
|
|
101
|
+
method,
|
|
102
|
+
url,
|
|
103
|
+
headers=dict(headers or {}),
|
|
104
|
+
json=json,
|
|
105
|
+
data=dict(form) if form is not None else None,
|
|
106
|
+
content=content,
|
|
107
|
+
)
|
|
108
|
+
except (
|
|
109
|
+
httpx.TimeoutException,
|
|
110
|
+
httpx.NetworkError,
|
|
111
|
+
httpx.RemoteProtocolError,
|
|
112
|
+
httpx.ProxyError,
|
|
113
|
+
) as error:
|
|
114
|
+
if last:
|
|
115
|
+
reason = (
|
|
116
|
+
"timeout"
|
|
117
|
+
if isinstance(error, httpx.TimeoutException)
|
|
118
|
+
else "connection_error"
|
|
119
|
+
)
|
|
120
|
+
raise DependencyUnavailable(dependency, method, reason) from error
|
|
121
|
+
else:
|
|
122
|
+
if response.status_code not in RETRY_STATUS or last:
|
|
123
|
+
return HttpResponse(
|
|
124
|
+
response.status_code, dict(response.headers), response.content
|
|
125
|
+
)
|
|
126
|
+
await asyncio.sleep(self._backoff * 2**attempt)
|
|
127
|
+
raise AssertionError("unreachable")
|
|
128
|
+
|
|
129
|
+
async def aclose(self) -> None:
|
|
130
|
+
await asyncio.gather(*(client.aclose() for client in self._clients.values()))
|
|
131
|
+
self._clients.clear()
|