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.
@@ -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()
@@ -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()