millwork-solver 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.
- millwork_solver/__init__.py +24 -0
- millwork_solver/_contract.json +292 -0
- millwork_solver/_core/__init__.py +3 -0
- millwork_solver/_core/boundary.py +110 -0
- millwork_solver/_core/catalog.py +218 -0
- millwork_solver/_core/clients.py +264 -0
- millwork_solver/_core/errors.py +11 -0
- millwork_solver/_core/transport.py +274 -0
- millwork_solver/client.py +251 -0
- millwork_solver/errors.py +62 -0
- millwork_solver/py.typed +1 -0
- millwork_solver/types.py +15 -0
- millwork_solver-0.1.0.dist-info/METADATA +32 -0
- millwork_solver-0.1.0.dist-info/RECORD +17 -0
- millwork_solver-0.1.0.dist-info/WHEEL +5 -0
- millwork_solver-0.1.0.dist-info/licenses/LICENSE +201 -0
- millwork_solver-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Repository-internal sync and async clients over the shared wire policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
6
|
+
from typing import Any, Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from ..types import Page
|
|
9
|
+
from .catalog import _OperationCatalog
|
|
10
|
+
from .errors import _ClientClosedError, _ProtocolResponseError
|
|
11
|
+
from .transport import _AsyncTransport, _SyncTransport, _TransportPolicy, _prepare_request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _page_from_response(response: Any, item_field: str) -> Page:
|
|
15
|
+
if not isinstance(response, dict):
|
|
16
|
+
raise _ProtocolResponseError("paginated response must be an object")
|
|
17
|
+
items = response.get(item_field)
|
|
18
|
+
cursor = response.get("next_cursor")
|
|
19
|
+
if not isinstance(items, list) or (cursor is not None and not isinstance(cursor, str)):
|
|
20
|
+
raise _ProtocolResponseError("paginated response has the wrong shape")
|
|
21
|
+
return Page(tuple(items), cursor)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _events_from_response(response: Any) -> tuple[Mapping[str, Any], ...]:
|
|
25
|
+
if not isinstance(response, dict) or not isinstance(response.get("events"), list):
|
|
26
|
+
raise _ProtocolResponseError("events response has the wrong shape")
|
|
27
|
+
events = response["events"]
|
|
28
|
+
if any(not isinstance(event, dict) for event in events):
|
|
29
|
+
raise _ProtocolResponseError("event entries must be objects")
|
|
30
|
+
return tuple(events)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_terminal_event(event: Mapping[str, Any]) -> bool:
|
|
34
|
+
return (
|
|
35
|
+
event.get("type") == "lifecycle"
|
|
36
|
+
and event.get("slice_id") is None
|
|
37
|
+
and event.get("state") in {"completed", "failed", "cancelled", "expired"}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _SyncClient:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
catalog: _OperationCatalog,
|
|
45
|
+
policy: _TransportPolicy,
|
|
46
|
+
transport: _SyncTransport,
|
|
47
|
+
*,
|
|
48
|
+
close_transport: Callable[[], None],
|
|
49
|
+
sleep: Callable[[float], None],
|
|
50
|
+
) -> None:
|
|
51
|
+
self._catalog = catalog
|
|
52
|
+
self._policy = policy
|
|
53
|
+
self._transport = transport
|
|
54
|
+
self._close_transport = close_transport
|
|
55
|
+
self._sleep = sleep
|
|
56
|
+
self._closed = False
|
|
57
|
+
|
|
58
|
+
def _ensure_open(self) -> None:
|
|
59
|
+
if self._closed:
|
|
60
|
+
raise _ClientClosedError("client is closed")
|
|
61
|
+
|
|
62
|
+
def _resolve_fixture_request(self, method: str, path: str) -> tuple[str, Mapping[str, str]]:
|
|
63
|
+
return self._catalog.match_fixture_request(method, path)
|
|
64
|
+
|
|
65
|
+
def request(
|
|
66
|
+
self,
|
|
67
|
+
operation_id: str,
|
|
68
|
+
*,
|
|
69
|
+
path_parameters: Mapping[str, object] | None = None,
|
|
70
|
+
query: Mapping[str, object] | None = None,
|
|
71
|
+
body: object | None = None,
|
|
72
|
+
idempotency_key: str | None = None,
|
|
73
|
+
) -> Any:
|
|
74
|
+
self._ensure_open()
|
|
75
|
+
operation = self._catalog.operation(operation_id)
|
|
76
|
+
prepared = _prepare_request(
|
|
77
|
+
operation_id=operation.operation_id,
|
|
78
|
+
method=operation.method,
|
|
79
|
+
path=operation.bind_path(path_parameters),
|
|
80
|
+
query=query,
|
|
81
|
+
body=body,
|
|
82
|
+
idempotency_key=idempotency_key,
|
|
83
|
+
policy=self._policy,
|
|
84
|
+
)
|
|
85
|
+
return self._transport.execute(prepared)
|
|
86
|
+
|
|
87
|
+
def pages(
|
|
88
|
+
self,
|
|
89
|
+
operation_id: str,
|
|
90
|
+
*,
|
|
91
|
+
item_field: str,
|
|
92
|
+
path_parameters: Mapping[str, object] | None = None,
|
|
93
|
+
query: Mapping[str, object] | None = None,
|
|
94
|
+
) -> Iterator[Page]:
|
|
95
|
+
cursor: str | None = None
|
|
96
|
+
while True:
|
|
97
|
+
page_query = dict(query or {})
|
|
98
|
+
if cursor is not None:
|
|
99
|
+
page_query["cursor"] = cursor
|
|
100
|
+
page = _page_from_response(
|
|
101
|
+
self.request(
|
|
102
|
+
operation_id,
|
|
103
|
+
path_parameters=path_parameters,
|
|
104
|
+
query=page_query,
|
|
105
|
+
),
|
|
106
|
+
item_field,
|
|
107
|
+
)
|
|
108
|
+
yield page
|
|
109
|
+
if page.next_cursor is None:
|
|
110
|
+
return
|
|
111
|
+
cursor = page.next_cursor
|
|
112
|
+
|
|
113
|
+
def events(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
path_parameters: Mapping[str, object],
|
|
117
|
+
poll_interval_seconds: float = 1.0,
|
|
118
|
+
) -> Iterator[Mapping[str, Any]]:
|
|
119
|
+
if poll_interval_seconds < 0:
|
|
120
|
+
raise ValueError("poll_interval_seconds must not be negative")
|
|
121
|
+
after: str | None = None
|
|
122
|
+
while True:
|
|
123
|
+
events = _events_from_response(
|
|
124
|
+
self.request(
|
|
125
|
+
self._catalog.event_operation_id,
|
|
126
|
+
path_parameters=path_parameters,
|
|
127
|
+
query={"after": after},
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
if not events:
|
|
131
|
+
self._sleep(poll_interval_seconds)
|
|
132
|
+
for event in events:
|
|
133
|
+
yield event
|
|
134
|
+
event_id = event.get("event_id")
|
|
135
|
+
if not isinstance(event_id, str):
|
|
136
|
+
raise _ProtocolResponseError("event_id must be a string")
|
|
137
|
+
after = event_id
|
|
138
|
+
if _is_terminal_event(event):
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
if not self._closed:
|
|
143
|
+
self._closed = True
|
|
144
|
+
self._close_transport()
|
|
145
|
+
|
|
146
|
+
def __enter__(self) -> "_SyncClient":
|
|
147
|
+
self._ensure_open()
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def __exit__(self, *_: object) -> None:
|
|
151
|
+
self.close()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _AsyncClient:
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
catalog: _OperationCatalog,
|
|
158
|
+
policy: _TransportPolicy,
|
|
159
|
+
transport: _AsyncTransport,
|
|
160
|
+
*,
|
|
161
|
+
close_transport: Callable[[], Awaitable[None]],
|
|
162
|
+
sleep: Callable[[float], Awaitable[None]],
|
|
163
|
+
) -> None:
|
|
164
|
+
self._catalog = catalog
|
|
165
|
+
self._policy = policy
|
|
166
|
+
self._transport = transport
|
|
167
|
+
self._close_transport = close_transport
|
|
168
|
+
self._sleep = sleep
|
|
169
|
+
self._closed = False
|
|
170
|
+
|
|
171
|
+
def _ensure_open(self) -> None:
|
|
172
|
+
if self._closed:
|
|
173
|
+
raise _ClientClosedError("client is closed")
|
|
174
|
+
|
|
175
|
+
def _resolve_fixture_request(self, method: str, path: str) -> tuple[str, Mapping[str, str]]:
|
|
176
|
+
return self._catalog.match_fixture_request(method, path)
|
|
177
|
+
|
|
178
|
+
async def request(
|
|
179
|
+
self,
|
|
180
|
+
operation_id: str,
|
|
181
|
+
*,
|
|
182
|
+
path_parameters: Mapping[str, object] | None = None,
|
|
183
|
+
query: Mapping[str, object] | None = None,
|
|
184
|
+
body: object | None = None,
|
|
185
|
+
idempotency_key: str | None = None,
|
|
186
|
+
) -> Any:
|
|
187
|
+
self._ensure_open()
|
|
188
|
+
operation = self._catalog.operation(operation_id)
|
|
189
|
+
prepared = _prepare_request(
|
|
190
|
+
operation_id=operation.operation_id,
|
|
191
|
+
method=operation.method,
|
|
192
|
+
path=operation.bind_path(path_parameters),
|
|
193
|
+
query=query,
|
|
194
|
+
body=body,
|
|
195
|
+
idempotency_key=idempotency_key,
|
|
196
|
+
policy=self._policy,
|
|
197
|
+
)
|
|
198
|
+
return await self._transport.execute(prepared)
|
|
199
|
+
|
|
200
|
+
async def pages(
|
|
201
|
+
self,
|
|
202
|
+
operation_id: str,
|
|
203
|
+
*,
|
|
204
|
+
item_field: str,
|
|
205
|
+
path_parameters: Mapping[str, object] | None = None,
|
|
206
|
+
query: Mapping[str, object] | None = None,
|
|
207
|
+
) -> AsyncIterator[Page]:
|
|
208
|
+
cursor: str | None = None
|
|
209
|
+
while True:
|
|
210
|
+
page_query = dict(query or {})
|
|
211
|
+
if cursor is not None:
|
|
212
|
+
page_query["cursor"] = cursor
|
|
213
|
+
page = _page_from_response(
|
|
214
|
+
await self.request(
|
|
215
|
+
operation_id,
|
|
216
|
+
path_parameters=path_parameters,
|
|
217
|
+
query=page_query,
|
|
218
|
+
),
|
|
219
|
+
item_field,
|
|
220
|
+
)
|
|
221
|
+
yield page
|
|
222
|
+
if page.next_cursor is None:
|
|
223
|
+
return
|
|
224
|
+
cursor = page.next_cursor
|
|
225
|
+
|
|
226
|
+
async def events(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
path_parameters: Mapping[str, object],
|
|
230
|
+
poll_interval_seconds: float = 1.0,
|
|
231
|
+
) -> AsyncIterator[Mapping[str, Any]]:
|
|
232
|
+
if poll_interval_seconds < 0:
|
|
233
|
+
raise ValueError("poll_interval_seconds must not be negative")
|
|
234
|
+
after: str | None = None
|
|
235
|
+
while True:
|
|
236
|
+
events = _events_from_response(
|
|
237
|
+
await self.request(
|
|
238
|
+
self._catalog.event_operation_id,
|
|
239
|
+
path_parameters=path_parameters,
|
|
240
|
+
query={"after": after},
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
if not events:
|
|
244
|
+
await self._sleep(poll_interval_seconds)
|
|
245
|
+
for event in events:
|
|
246
|
+
yield event
|
|
247
|
+
event_id = event.get("event_id")
|
|
248
|
+
if not isinstance(event_id, str):
|
|
249
|
+
raise _ProtocolResponseError("event_id must be a string")
|
|
250
|
+
after = event_id
|
|
251
|
+
if _is_terminal_event(event):
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
async def aclose(self) -> None:
|
|
255
|
+
if not self._closed:
|
|
256
|
+
self._closed = True
|
|
257
|
+
await self._close_transport()
|
|
258
|
+
|
|
259
|
+
async def __aenter__(self) -> "_AsyncClient":
|
|
260
|
+
self._ensure_open()
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
async def __aexit__(self, *_: object) -> None:
|
|
264
|
+
await self.aclose()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Private compatibility names bound to the stable public error classes."""
|
|
2
|
+
|
|
3
|
+
from ..errors import (
|
|
4
|
+
ClientClosedError as _ClientClosedError,
|
|
5
|
+
NetworkError as _NetworkTransportError,
|
|
6
|
+
OperationBoundaryError as _OperationBoundaryError,
|
|
7
|
+
ProblemError as _ProblemResponseError,
|
|
8
|
+
ProtocolError as _ProtocolResponseError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__: tuple[str, ...] = ()
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Shared safe-transport policy with distinct sync and async HTTPX execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Awaitable, Callable, Mapping
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import _NetworkTransportError, _ProblemResponseError, _ProtocolResponseError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _RetryBoundary(Enum):
|
|
17
|
+
SAFE_READ = "safe_read"
|
|
18
|
+
SAME_KEY_MUTATION = "same_key_mutation"
|
|
19
|
+
SINGLE_ATTEMPT_MUTATION = "single_attempt_mutation"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class _TransportPolicy:
|
|
24
|
+
safe_methods: frozenset[str]
|
|
25
|
+
maximum_retries: int
|
|
26
|
+
backoff_seconds: float
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_repository(
|
|
30
|
+
cls,
|
|
31
|
+
repository_root: Path,
|
|
32
|
+
*,
|
|
33
|
+
backoff_seconds: float,
|
|
34
|
+
maximum_retries: int | None = None,
|
|
35
|
+
) -> "_TransportPolicy":
|
|
36
|
+
fixture_path = repository_root / "contracts/developer-experience/fixtures/retry-error-idempotency.json"
|
|
37
|
+
with fixture_path.open("r", encoding="utf-8") as file:
|
|
38
|
+
fixture = json.load(file)
|
|
39
|
+
policy = fixture.get("policy", {})
|
|
40
|
+
if policy.get("safe_methods") != ["GET", "HEAD"]:
|
|
41
|
+
raise _ProtocolResponseError("F1 safe-method policy drifted")
|
|
42
|
+
if policy.get("retryable_failures") != ["network_error", "http_5xx"]:
|
|
43
|
+
raise _ProtocolResponseError("F1 retryable-failure policy drifted")
|
|
44
|
+
fixture_maximum_retries = policy.get("maximum_retries")
|
|
45
|
+
if not isinstance(fixture_maximum_retries, int) or fixture_maximum_retries < 0:
|
|
46
|
+
raise _ProtocolResponseError("F1 maximum-retries policy drifted")
|
|
47
|
+
selected_maximum_retries = (
|
|
48
|
+
fixture_maximum_retries if maximum_retries is None else maximum_retries
|
|
49
|
+
)
|
|
50
|
+
if not isinstance(selected_maximum_retries, int) or selected_maximum_retries < 0:
|
|
51
|
+
raise ValueError("maximum_retries must be a non-negative integer")
|
|
52
|
+
if backoff_seconds < 0:
|
|
53
|
+
raise ValueError("backoff_seconds must not be negative")
|
|
54
|
+
return cls(frozenset(policy["safe_methods"]), selected_maximum_retries, backoff_seconds)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_snapshot(
|
|
58
|
+
cls,
|
|
59
|
+
snapshot_path: Path,
|
|
60
|
+
*,
|
|
61
|
+
backoff_seconds: float,
|
|
62
|
+
maximum_retries: int | None = None,
|
|
63
|
+
) -> "_TransportPolicy":
|
|
64
|
+
with snapshot_path.open("r", encoding="utf-8") as file:
|
|
65
|
+
snapshot = json.load(file)
|
|
66
|
+
policy = snapshot.get("retry_policy", {})
|
|
67
|
+
if policy.get("safe_methods") != ["GET", "HEAD"]:
|
|
68
|
+
raise _ProtocolResponseError("installed F1 safe-method policy drifted")
|
|
69
|
+
if policy.get("retryable_failures") != ["network_error", "http_5xx"]:
|
|
70
|
+
raise _ProtocolResponseError("installed F1 retryable-failure policy drifted")
|
|
71
|
+
default_maximum_retries = policy.get("maximum_retries")
|
|
72
|
+
if not isinstance(default_maximum_retries, int) or default_maximum_retries < 0:
|
|
73
|
+
raise _ProtocolResponseError("installed F1 maximum-retries policy drifted")
|
|
74
|
+
selected_maximum_retries = (
|
|
75
|
+
default_maximum_retries if maximum_retries is None else maximum_retries
|
|
76
|
+
)
|
|
77
|
+
if not isinstance(selected_maximum_retries, int) or selected_maximum_retries < 0:
|
|
78
|
+
raise ValueError("maximum_retries must be a non-negative integer")
|
|
79
|
+
if backoff_seconds < 0:
|
|
80
|
+
raise ValueError("backoff_seconds must not be negative")
|
|
81
|
+
return cls(frozenset(policy["safe_methods"]), selected_maximum_retries, backoff_seconds)
|
|
82
|
+
|
|
83
|
+
def classify(self, method: str, idempotency_key: str | None) -> _RetryBoundary:
|
|
84
|
+
if method in self.safe_methods:
|
|
85
|
+
return _RetryBoundary.SAFE_READ
|
|
86
|
+
if idempotency_key is not None and idempotency_key.strip():
|
|
87
|
+
return _RetryBoundary.SAME_KEY_MUTATION
|
|
88
|
+
return _RetryBoundary.SINGLE_ATTEMPT_MUTATION
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class _PreparedRequest:
|
|
93
|
+
operation_id: str
|
|
94
|
+
method: str
|
|
95
|
+
path: str
|
|
96
|
+
query_items: tuple[tuple[str, str], ...]
|
|
97
|
+
header_items: tuple[tuple[str, str], ...]
|
|
98
|
+
body_bytes: bytes | None
|
|
99
|
+
retry_boundary: _RetryBoundary
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _encode_query_value(value: object) -> str:
|
|
103
|
+
if isinstance(value, bool):
|
|
104
|
+
return "true" if value else "false"
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return value
|
|
107
|
+
if isinstance(value, int):
|
|
108
|
+
return str(value)
|
|
109
|
+
if isinstance(value, float):
|
|
110
|
+
try:
|
|
111
|
+
return json.dumps(value, allow_nan=False)
|
|
112
|
+
except ValueError as error:
|
|
113
|
+
raise TypeError("query numbers must be finite JSON scalars") from error
|
|
114
|
+
raise TypeError("query values must be string, number, boolean, or None")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _prepare_request(
|
|
118
|
+
*,
|
|
119
|
+
operation_id: str,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
query: Mapping[str, object] | None,
|
|
123
|
+
body: object | None,
|
|
124
|
+
idempotency_key: str | None,
|
|
125
|
+
policy: _TransportPolicy,
|
|
126
|
+
) -> _PreparedRequest:
|
|
127
|
+
body_bytes = None
|
|
128
|
+
if body is not None:
|
|
129
|
+
body_bytes = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
130
|
+
headers = {"accept": "application/json"}
|
|
131
|
+
if body_bytes is not None:
|
|
132
|
+
headers["content-type"] = "application/json"
|
|
133
|
+
if idempotency_key is not None:
|
|
134
|
+
headers["idempotency-key"] = idempotency_key
|
|
135
|
+
query_items = tuple(
|
|
136
|
+
(name, _encode_query_value(value))
|
|
137
|
+
for name, value in (query or {}).items()
|
|
138
|
+
if value is not None
|
|
139
|
+
)
|
|
140
|
+
return _PreparedRequest(
|
|
141
|
+
operation_id=operation_id,
|
|
142
|
+
method=method,
|
|
143
|
+
path=path,
|
|
144
|
+
query_items=query_items,
|
|
145
|
+
header_items=tuple(headers.items()),
|
|
146
|
+
body_bytes=body_bytes,
|
|
147
|
+
retry_boundary=policy.classify(method, idempotency_key),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _decode_response(response: httpx.Response, method: str) -> Any:
|
|
152
|
+
if 200 <= response.status_code < 300:
|
|
153
|
+
if response.status_code == 204 or method == "HEAD":
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
return response.json()
|
|
157
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
158
|
+
raise _ProtocolResponseError("success response is not valid JSON") from error
|
|
159
|
+
|
|
160
|
+
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
161
|
+
if content_type != "application/problem+json":
|
|
162
|
+
raise _ProtocolResponseError("error response is not application/problem+json")
|
|
163
|
+
try:
|
|
164
|
+
problem = response.json()
|
|
165
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
166
|
+
raise _ProtocolResponseError("Problem response is not valid JSON") from error
|
|
167
|
+
if not isinstance(problem, dict):
|
|
168
|
+
raise _ProtocolResponseError("Problem response must be an object")
|
|
169
|
+
required_types = {
|
|
170
|
+
"type": str,
|
|
171
|
+
"title": str,
|
|
172
|
+
"status": int,
|
|
173
|
+
"instance": str,
|
|
174
|
+
}
|
|
175
|
+
for field, expected_type in required_types.items():
|
|
176
|
+
if not isinstance(problem.get(field), expected_type):
|
|
177
|
+
raise _ProtocolResponseError(f"Problem field {field} has the wrong type")
|
|
178
|
+
if problem["status"] != response.status_code:
|
|
179
|
+
raise _ProtocolResponseError("Problem status differs from HTTP status")
|
|
180
|
+
if "retry_after_s" in problem and not isinstance(problem["retry_after_s"], int):
|
|
181
|
+
raise _ProtocolResponseError("Problem retry_after_s has the wrong type")
|
|
182
|
+
if "errors" in problem:
|
|
183
|
+
errors = problem["errors"]
|
|
184
|
+
if not isinstance(errors, list) or any(
|
|
185
|
+
not isinstance(item, dict)
|
|
186
|
+
or not isinstance(item.get("field"), str)
|
|
187
|
+
or not isinstance(item.get("message"), str)
|
|
188
|
+
for item in errors
|
|
189
|
+
):
|
|
190
|
+
raise _ProtocolResponseError("Problem errors have the wrong shape")
|
|
191
|
+
raise _ProblemResponseError(problem)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _can_retry(prepared: _PreparedRequest, retries_used: int, policy: _TransportPolicy) -> bool:
|
|
195
|
+
return (
|
|
196
|
+
prepared.retry_boundary is not _RetryBoundary.SINGLE_ATTEMPT_MUTATION
|
|
197
|
+
and retries_used < policy.maximum_retries
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _SyncTransport:
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
client: httpx.Client,
|
|
205
|
+
policy: _TransportPolicy,
|
|
206
|
+
sleep: Callable[[float], None],
|
|
207
|
+
) -> None:
|
|
208
|
+
self._client = client
|
|
209
|
+
self._policy = policy
|
|
210
|
+
self._sleep = sleep
|
|
211
|
+
|
|
212
|
+
def execute(self, prepared: _PreparedRequest) -> Any:
|
|
213
|
+
retries_used = 0
|
|
214
|
+
while True:
|
|
215
|
+
try:
|
|
216
|
+
response = self._client.request(
|
|
217
|
+
prepared.method,
|
|
218
|
+
prepared.path,
|
|
219
|
+
params=prepared.query_items,
|
|
220
|
+
headers=dict(prepared.header_items),
|
|
221
|
+
content=prepared.body_bytes,
|
|
222
|
+
)
|
|
223
|
+
except httpx.TransportError as error:
|
|
224
|
+
if _can_retry(prepared, retries_used, self._policy):
|
|
225
|
+
self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
|
|
226
|
+
retries_used += 1
|
|
227
|
+
continue
|
|
228
|
+
raise _NetworkTransportError(
|
|
229
|
+
f"{prepared.operation_id} failed after {retries_used + 1} attempt(s)",
|
|
230
|
+
cause=error,
|
|
231
|
+
) from error
|
|
232
|
+
if response.status_code >= 500 and _can_retry(prepared, retries_used, self._policy):
|
|
233
|
+
self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
|
|
234
|
+
retries_used += 1
|
|
235
|
+
continue
|
|
236
|
+
return _decode_response(response, prepared.method)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class _AsyncTransport:
|
|
240
|
+
def __init__(
|
|
241
|
+
self,
|
|
242
|
+
client: httpx.AsyncClient,
|
|
243
|
+
policy: _TransportPolicy,
|
|
244
|
+
sleep: Callable[[float], Awaitable[None]],
|
|
245
|
+
) -> None:
|
|
246
|
+
self._client = client
|
|
247
|
+
self._policy = policy
|
|
248
|
+
self._sleep = sleep
|
|
249
|
+
|
|
250
|
+
async def execute(self, prepared: _PreparedRequest) -> Any:
|
|
251
|
+
retries_used = 0
|
|
252
|
+
while True:
|
|
253
|
+
try:
|
|
254
|
+
response = await self._client.request(
|
|
255
|
+
prepared.method,
|
|
256
|
+
prepared.path,
|
|
257
|
+
params=prepared.query_items,
|
|
258
|
+
headers=dict(prepared.header_items),
|
|
259
|
+
content=prepared.body_bytes,
|
|
260
|
+
)
|
|
261
|
+
except httpx.TransportError as error:
|
|
262
|
+
if _can_retry(prepared, retries_used, self._policy):
|
|
263
|
+
await self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
|
|
264
|
+
retries_used += 1
|
|
265
|
+
continue
|
|
266
|
+
raise _NetworkTransportError(
|
|
267
|
+
f"{prepared.operation_id} failed after {retries_used + 1} attempt(s)",
|
|
268
|
+
cause=error,
|
|
269
|
+
) from error
|
|
270
|
+
if response.status_code >= 500 and _can_retry(prepared, retries_used, self._policy):
|
|
271
|
+
await self._sleep(self._policy.backoff_seconds * (2 ** retries_used))
|
|
272
|
+
retries_used += 1
|
|
273
|
+
continue
|
|
274
|
+
return _decode_response(response, prepared.method)
|