fairagro-middleware-api-client 11.1.1.dev34__tar.gz → 12.0.1.dev35__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.
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/PKG-INFO +2 -2
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/pyproject.toml +0 -3
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/api_client.py +77 -27
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_client.py +72 -10
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/.gitignore +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/README.md +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/example_client_config.yaml +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/__init__.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/config.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/models.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/py.typed +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/client_test_support.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/conftest.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/integration/conftest.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/integration/test_create_arcs.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/conftest.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_api_client_config.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_client_config.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_harvest_arcs.py +0 -0
- {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_retry_logic.py +0 -0
|
@@ -36,9 +36,6 @@ markers = [
|
|
|
36
36
|
[tool.hatch.build.targets.wheel]
|
|
37
37
|
packages = ["src/middleware"]
|
|
38
38
|
|
|
39
|
-
[tool.ruff]
|
|
40
|
-
extend = "../../pyproject.toml"
|
|
41
|
-
|
|
42
39
|
[tool.hatch.version]
|
|
43
40
|
source = "vcs"
|
|
44
41
|
raw-options = { root = "../..", fallback_version = "0.0.0", tag_regex = "^(?:v|.*-(?:chart|docker)-v)(?P<version>\\d+\\.\\d+\\.\\d+)(?:$|[-+].*)" }
|
|
@@ -5,7 +5,8 @@ import json
|
|
|
5
5
|
import logging
|
|
6
6
|
import ssl
|
|
7
7
|
import threading
|
|
8
|
-
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
|
|
9
10
|
from contextlib import asynccontextmanager
|
|
10
11
|
from datetime import UTC, datetime
|
|
11
12
|
from http import HTTPStatus
|
|
@@ -69,12 +70,21 @@ class ApiClient:
|
|
|
69
70
|
_global_in_flight_requests: int = 0
|
|
70
71
|
_global_state_lock = threading.Lock()
|
|
71
72
|
|
|
73
|
+
@classmethod
|
|
74
|
+
def _has_idempotency_key(cls, headers: Mapping[str, str] | None) -> bool:
|
|
75
|
+
"""Return True when headers contain a non-empty Idempotency-Key."""
|
|
76
|
+
if not headers:
|
|
77
|
+
return False
|
|
78
|
+
for key, value in headers.items():
|
|
79
|
+
if key.lower() == "idempotency-key":
|
|
80
|
+
return bool(str(value).strip())
|
|
81
|
+
return False
|
|
82
|
+
|
|
72
83
|
@classmethod
|
|
73
84
|
def _is_idempotent_arc_post_path(cls, path: str) -> bool:
|
|
74
85
|
"""Return whether *path* is a server-idempotent ARC POST endpoint.
|
|
75
86
|
|
|
76
87
|
Covers ``POST /v3/arcs`` and ``POST /v3/harvests/{harvest_id}/arcs``.
|
|
77
|
-
Other POSTs (create/complete harvest) are not safe to retry.
|
|
78
88
|
"""
|
|
79
89
|
normalized = path.lstrip("/")
|
|
80
90
|
if normalized == "v3/arcs":
|
|
@@ -86,11 +96,29 @@ class ApiClient:
|
|
|
86
96
|
)
|
|
87
97
|
|
|
88
98
|
@classmethod
|
|
89
|
-
def
|
|
99
|
+
def _is_idempotent_post_path(cls, path: str, headers: Mapping[str, str] | None = None) -> bool:
|
|
100
|
+
"""Return whether a POST path is safe to retry with an identical request.
|
|
101
|
+
|
|
102
|
+
Covers idempotent ARC POSTs and ``POST /v3/harvests`` only when a
|
|
103
|
+
non-empty ``Idempotency-Key`` header is present. Harvest completion
|
|
104
|
+
remains non-retryable.
|
|
105
|
+
"""
|
|
106
|
+
normalized = path.lstrip("/")
|
|
107
|
+
if normalized == "v3/harvests":
|
|
108
|
+
return cls._has_idempotency_key(headers)
|
|
109
|
+
return cls._is_idempotent_arc_post_path(path)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def _is_retryable_method(
|
|
113
|
+
cls,
|
|
114
|
+
method: str,
|
|
115
|
+
path: str,
|
|
116
|
+
headers: Mapping[str, str] | None = None,
|
|
117
|
+
) -> bool:
|
|
90
118
|
"""Return whether failures for this method/path may be retried."""
|
|
91
119
|
if method in cls._IDEMPOTENT_METHODS:
|
|
92
120
|
return True
|
|
93
|
-
return method == "POST" and cls.
|
|
121
|
+
return method == "POST" and cls._is_idempotent_post_path(path, headers)
|
|
94
122
|
|
|
95
123
|
@classmethod
|
|
96
124
|
def _configure_global_request_limiter(cls, max_concurrency: int) -> None:
|
|
@@ -132,15 +160,27 @@ class ApiClient:
|
|
|
132
160
|
limiter.release()
|
|
133
161
|
|
|
134
162
|
@classmethod
|
|
135
|
-
def _should_retry_http_status(
|
|
163
|
+
def _should_retry_http_status(
|
|
164
|
+
cls,
|
|
165
|
+
method: str,
|
|
166
|
+
path: str,
|
|
167
|
+
status_code: int,
|
|
168
|
+
headers: Mapping[str, str] | None = None,
|
|
169
|
+
) -> bool:
|
|
136
170
|
"""Return whether a response status is retryable for a method/path."""
|
|
137
171
|
transient = {httpx.codes.BAD_GATEWAY, httpx.codes.SERVICE_UNAVAILABLE, httpx.codes.GATEWAY_TIMEOUT}
|
|
138
|
-
return cls._is_retryable_method(method, path) and status_code in transient
|
|
172
|
+
return cls._is_retryable_method(method, path, headers) and status_code in transient
|
|
139
173
|
|
|
140
174
|
@classmethod
|
|
141
|
-
def _should_retry_request_error(
|
|
175
|
+
def _should_retry_request_error(
|
|
176
|
+
cls,
|
|
177
|
+
method: str,
|
|
178
|
+
path: str,
|
|
179
|
+
error: httpx.RequestError,
|
|
180
|
+
headers: Mapping[str, str] | None = None,
|
|
181
|
+
) -> bool:
|
|
142
182
|
"""Return whether a request error is retryable for a method/path."""
|
|
143
|
-
if not cls._is_retryable_method(method, path):
|
|
183
|
+
if not cls._is_retryable_method(method, path, headers):
|
|
144
184
|
return False
|
|
145
185
|
return not isinstance(error, httpx.TimeoutException)
|
|
146
186
|
|
|
@@ -152,12 +192,13 @@ class ApiClient:
|
|
|
152
192
|
*,
|
|
153
193
|
status_code: int | None = None,
|
|
154
194
|
request_error: httpx.RequestError | None = None,
|
|
195
|
+
headers: Mapping[str, str] | None = None,
|
|
155
196
|
) -> bool:
|
|
156
197
|
"""Return whether an HTTP failure is retryable for a request method/path."""
|
|
157
198
|
if status_code is not None:
|
|
158
|
-
return cls._should_retry_http_status(method, path, status_code)
|
|
199
|
+
return cls._should_retry_http_status(method, path, status_code, headers)
|
|
159
200
|
if request_error is not None:
|
|
160
|
-
return cls._should_retry_request_error(method, path, request_error)
|
|
201
|
+
return cls._should_retry_request_error(method, path, request_error, headers)
|
|
161
202
|
return False
|
|
162
203
|
|
|
163
204
|
@classmethod
|
|
@@ -202,22 +243,11 @@ class ApiClient:
|
|
|
202
243
|
cls,
|
|
203
244
|
failure: httpx.HTTPStatusError | httpx.RequestError,
|
|
204
245
|
*,
|
|
205
|
-
|
|
206
|
-
path: str,
|
|
246
|
+
should_retry: bool,
|
|
207
247
|
attempt: int,
|
|
208
248
|
max_retries: int,
|
|
209
249
|
) -> bool:
|
|
210
250
|
"""Return True to retry; otherwise raise a normalized ApiClientError."""
|
|
211
|
-
status_code = failure.response.status_code if isinstance(failure, httpx.HTTPStatusError) else None
|
|
212
|
-
request_error = failure if isinstance(failure, httpx.RequestError) else None
|
|
213
|
-
|
|
214
|
-
should_retry = cls._should_retry_failure(
|
|
215
|
-
method,
|
|
216
|
-
path,
|
|
217
|
-
status_code=status_code,
|
|
218
|
-
request_error=request_error,
|
|
219
|
-
)
|
|
220
|
-
|
|
221
251
|
if should_retry and attempt < max_retries:
|
|
222
252
|
if isinstance(failure, httpx.HTTPStatusError):
|
|
223
253
|
logger.warning("Transient HTTP error %d from server, will retry", failure.response.status_code)
|
|
@@ -474,6 +504,8 @@ class ApiClient:
|
|
|
474
504
|
client = self._get_client()
|
|
475
505
|
path = path.lstrip("/")
|
|
476
506
|
method = method.upper()
|
|
507
|
+
raw_headers = kwargs.get("headers")
|
|
508
|
+
headers = cast(Mapping[str, str], raw_headers) if isinstance(raw_headers, Mapping) else None
|
|
477
509
|
|
|
478
510
|
for attempt in range(self._config.max_retries + 1):
|
|
479
511
|
if attempt > 0:
|
|
@@ -489,7 +521,7 @@ class ApiClient:
|
|
|
489
521
|
resp = await client.request(method, path, **kwargs)
|
|
490
522
|
|
|
491
523
|
# Retry on transient server-side errors before raising
|
|
492
|
-
should_retry = self._should_retry_failure(method, path, status_code=resp.status_code)
|
|
524
|
+
should_retry = self._should_retry_failure(method, path, status_code=resp.status_code, headers=headers)
|
|
493
525
|
if should_retry and attempt < self._config.max_retries:
|
|
494
526
|
logger.warning("Transient HTTP error %d from server, will retry", resp.status_code)
|
|
495
527
|
continue
|
|
@@ -500,10 +532,18 @@ class ApiClient:
|
|
|
500
532
|
return self._parse_json_response(resp, method, path)
|
|
501
533
|
|
|
502
534
|
except (httpx.HTTPStatusError, httpx.RequestError) as e:
|
|
535
|
+
status_code = e.response.status_code if isinstance(e, httpx.HTTPStatusError) else None
|
|
536
|
+
request_error = e if isinstance(e, httpx.RequestError) else None
|
|
537
|
+
should_retry = self._should_retry_failure(
|
|
538
|
+
method,
|
|
539
|
+
path,
|
|
540
|
+
status_code=status_code,
|
|
541
|
+
request_error=request_error,
|
|
542
|
+
headers=headers,
|
|
543
|
+
)
|
|
503
544
|
if self._should_retry_or_raise_failure(
|
|
504
545
|
e,
|
|
505
|
-
|
|
506
|
-
path=path,
|
|
546
|
+
should_retry=should_retry,
|
|
507
547
|
attempt=attempt,
|
|
508
548
|
max_retries=self._config.max_retries,
|
|
509
549
|
):
|
|
@@ -633,7 +673,8 @@ class ApiClient:
|
|
|
633
673
|
) -> HarvestResult:
|
|
634
674
|
"""Start a new harvest run.
|
|
635
675
|
|
|
636
|
-
Uses ``POST /v3/harvests
|
|
676
|
+
Uses ``POST /v3/harvests`` with a generated ``Idempotency-Key`` so the
|
|
677
|
+
request is safe to retry after transport failures.
|
|
637
678
|
|
|
638
679
|
Args:
|
|
639
680
|
rdi: RDI identifier.
|
|
@@ -643,7 +684,16 @@ class ApiClient:
|
|
|
643
684
|
:class:`HarvestResult` with the newly created harvest.
|
|
644
685
|
"""
|
|
645
686
|
request = CreateHarvestRequest(rdi=rdi, expected_datasets=expected_datasets)
|
|
646
|
-
|
|
687
|
+
idempotency_key = str(uuid.uuid4())
|
|
688
|
+
data = await self._request_with_retries(
|
|
689
|
+
"POST",
|
|
690
|
+
"v3/harvests",
|
|
691
|
+
content=request.model_dump_json(by_alias=True),
|
|
692
|
+
headers={
|
|
693
|
+
"content-type": "application/json",
|
|
694
|
+
"Idempotency-Key": idempotency_key,
|
|
695
|
+
},
|
|
696
|
+
)
|
|
647
697
|
return self._parse_harvest_response(data)
|
|
648
698
|
|
|
649
699
|
async def list_harvests(
|
|
@@ -252,16 +252,72 @@ async def test_create_or_update_arc_retries_on_connect_error(client_config: Conf
|
|
|
252
252
|
|
|
253
253
|
@pytest.mark.asyncio
|
|
254
254
|
@respx.mock
|
|
255
|
-
async def
|
|
256
|
-
"""
|
|
255
|
+
async def test_create_harvest_sends_idempotency_key(client_config: Config) -> None:
|
|
256
|
+
"""create_harvest always sends a non-empty Idempotency-Key header."""
|
|
257
|
+
route = respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
258
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json=HARVEST_RESPONSE)
|
|
259
|
+
)
|
|
260
|
+
async with ApiClient(client_config) as client:
|
|
261
|
+
await client.create_harvest(rdi="test-rdi")
|
|
262
|
+
|
|
263
|
+
assert route.called
|
|
264
|
+
key = route.calls.last.request.headers.get("idempotency-key")
|
|
265
|
+
assert key
|
|
266
|
+
assert len(key) > 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.asyncio
|
|
270
|
+
@respx.mock
|
|
271
|
+
async def test_create_harvest_retries_on_connect_error(client_config: Config) -> None:
|
|
272
|
+
"""Keyed harvest create retries ConnectError then succeeds."""
|
|
257
273
|
client_config.retry_backoff_factor = 0.01
|
|
274
|
+
client_config.max_retries = 2
|
|
275
|
+
seen_keys: list[str] = []
|
|
276
|
+
|
|
277
|
+
def _handler(request: httpx.Request) -> httpx.Response:
|
|
278
|
+
seen_keys.append(request.headers["idempotency-key"])
|
|
279
|
+
if len(seen_keys) == 1:
|
|
280
|
+
raise httpx.ConnectError("Connection refused")
|
|
281
|
+
return httpx.Response(http.HTTPStatus.OK, json=HARVEST_RESPONSE)
|
|
282
|
+
|
|
283
|
+
route = respx.post(f"{client_config.api_url}v3/harvests").mock(side_effect=_handler)
|
|
284
|
+
async with ApiClient(client_config) as client:
|
|
285
|
+
result = await client.create_harvest(rdi="test-rdi")
|
|
286
|
+
|
|
287
|
+
assert result.harvest_id == HARVEST_RESPONSE["harvest_id"]
|
|
288
|
+
assert route.call_count == 2 # noqa: PLR2004
|
|
289
|
+
assert len(seen_keys) == 2 # noqa: PLR2004
|
|
290
|
+
assert seen_keys[0] == seen_keys[1]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@pytest.mark.asyncio
|
|
294
|
+
@respx.mock
|
|
295
|
+
async def test_unkeyed_harvest_create_post_not_retried(client_config: Config) -> None:
|
|
296
|
+
"""POST /v3/harvests without Idempotency-Key must not be retried."""
|
|
297
|
+
from middleware.shared.api_models.v3.models import CreateHarvestRequest
|
|
298
|
+
|
|
299
|
+
client_config.retry_backoff_factor = 0.01
|
|
300
|
+
client_config.max_retries = 2
|
|
258
301
|
route = respx.post(f"{client_config.api_url}v3/harvests").mock(side_effect=httpx.ConnectError("Connection refused"))
|
|
259
302
|
async with ApiClient(client_config) as client:
|
|
260
303
|
with pytest.raises(ApiClientError, match="Request failed: Connection refused"):
|
|
261
|
-
await client.
|
|
304
|
+
await client._post("v3/harvests", CreateHarvestRequest(rdi="test-rdi")) # noqa: SLF001
|
|
262
305
|
assert route.call_count == 1
|
|
263
306
|
|
|
264
307
|
|
|
308
|
+
@pytest.mark.asyncio
|
|
309
|
+
@respx.mock
|
|
310
|
+
async def test_create_harvest_exhausted_retries(client_config: Config) -> None:
|
|
311
|
+
"""Keyed harvest create raises after retries are exhausted."""
|
|
312
|
+
client_config.retry_backoff_factor = 0.01
|
|
313
|
+
client_config.max_retries = 1
|
|
314
|
+
route = respx.post(f"{client_config.api_url}v3/harvests").mock(side_effect=httpx.ConnectError("Connection refused"))
|
|
315
|
+
async with ApiClient(client_config) as client:
|
|
316
|
+
with pytest.raises(ApiClientError, match="Request failed after 1 retries"):
|
|
317
|
+
await client.create_harvest(rdi="test-rdi")
|
|
318
|
+
assert route.call_count == 2 # noqa: PLR2004
|
|
319
|
+
|
|
320
|
+
|
|
265
321
|
@pytest.mark.asyncio
|
|
266
322
|
@respx.mock
|
|
267
323
|
async def test_create_or_update_arc_invalid_response(client_config: Config) -> None:
|
|
@@ -384,9 +440,10 @@ def test_format_request_error_falls_back_when_message_empty() -> None:
|
|
|
384
440
|
@respx.mock
|
|
385
441
|
async def test_empty_connect_error_message_is_not_blank(client_config: Config) -> None:
|
|
386
442
|
"""ApiClientError for empty ConnectError still carries a useful message."""
|
|
443
|
+
client_config.max_retries = 0
|
|
387
444
|
respx.post(f"{client_config.api_url}v3/harvests").mock(side_effect=httpx.ConnectError(""))
|
|
388
445
|
async with ApiClient(client_config) as client:
|
|
389
|
-
with pytest.raises(ApiClientError, match="
|
|
446
|
+
with pytest.raises(ApiClientError, match="ConnectError") as exc_info:
|
|
390
447
|
await client.create_harvest(rdi="test-rdi")
|
|
391
448
|
assert str(exc_info.value).strip() != "Request failed:"
|
|
392
449
|
|
|
@@ -465,15 +522,20 @@ async def test_create_harvest_without_expected_datasets(client_config: Config) -
|
|
|
465
522
|
|
|
466
523
|
@pytest.mark.asyncio
|
|
467
524
|
@respx.mock
|
|
468
|
-
async def
|
|
469
|
-
"""
|
|
525
|
+
async def test_create_harvest_retries_on_503(client_config: Config) -> None:
|
|
526
|
+
"""Keyed harvest create retries transient 503 responses."""
|
|
527
|
+
client_config.retry_backoff_factor = 0.01
|
|
528
|
+
client_config.max_retries = 2
|
|
470
529
|
route = respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
471
|
-
|
|
530
|
+
side_effect=[
|
|
531
|
+
httpx.Response(http.HTTPStatus.SERVICE_UNAVAILABLE, text="Busy"),
|
|
532
|
+
httpx.Response(http.HTTPStatus.OK, json=HARVEST_RESPONSE),
|
|
533
|
+
]
|
|
472
534
|
)
|
|
473
535
|
async with ApiClient(client_config) as client:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
assert route.call_count ==
|
|
536
|
+
result = await client.create_harvest(rdi="test-rdi")
|
|
537
|
+
assert result.harvest_id == HARVEST_RESPONSE["harvest_id"]
|
|
538
|
+
assert route.call_count == 2 # noqa: PLR2004
|
|
477
539
|
|
|
478
540
|
|
|
479
541
|
@pytest.mark.asyncio
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|