fairagro-middleware-api-client 10.0.2.dev24__tar.gz → 10.0.3.dev26__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-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/PKG-INFO +1 -1
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/spec/harvest-client/design.md +15 -5
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/api_client.py +58 -13
- fairagro_middleware_api_client-10.0.3.dev26/tests/client_test_support.py +50 -0
- fairagro_middleware_api_client-10.0.3.dev26/tests/unit/conftest.py +11 -0
- fairagro_middleware_api_client-10.0.3.dev26/tests/unit/test_client.py +607 -0
- fairagro_middleware_api_client-10.0.3.dev26/tests/unit/test_harvest_arcs.py +414 -0
- fairagro_middleware_api_client-10.0.2.dev24/tests/unit/test_client.py +0 -999
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/.gitignore +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/README.md +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/example_client_config.yaml +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/pyproject.toml +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/spec/harvest-client/spec.md +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/__init__.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/config.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/models.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/py.typed +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/conftest.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/integration/conftest.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/integration/test_create_arcs.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/unit/test_api_client_config.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/unit/test_client_config.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/unit/test_retry_logic.py +0 -0
|
@@ -48,11 +48,13 @@ harvester
|
|
|
48
48
|
appended rather than overwriting.
|
|
49
49
|
|
|
50
50
|
5. **Duplicate detection is performed client-side before the HTTP request**
|
|
51
|
-
—
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
— Within one harvest batch, two different ARC payloads that share an
|
|
52
|
+
identifier are a harvester bug. Detecting them client-side yields an
|
|
53
|
+
explicit `DUPLICATE` error and avoids the round-trip. The server still
|
|
54
|
+
enforces harvest-local identity: identical re-submits (e.g. transport
|
|
55
|
+
retries) return `200`; conflicting content for the same identifier returns
|
|
56
|
+
`409` (see `harvest-arc-upload/`). Client-side detection therefore covers
|
|
57
|
+
*intra-batch* duplicates; server idempotency covers *retry* duplicates.
|
|
56
58
|
|
|
57
59
|
6. **Item-level failures are non-fatal; harvest-level failures are fatal**
|
|
58
60
|
— A submission failure for one ARC (e.g. server 422 on bad content) must
|
|
@@ -60,3 +62,11 @@ harvester
|
|
|
60
62
|
catastrophic failure (e.g. 401 Unauthorized, harvest already closed) means
|
|
61
63
|
no further submissions will succeed, so the harvest is aborted, marked
|
|
62
64
|
`FAILED`, and the exception propagates to the caller.
|
|
65
|
+
|
|
66
|
+
7. **POST ARC endpoints retry transport failures**
|
|
67
|
+
— `POST /v3/arcs` and `POST /v3/harvests/{id}/arcs` are server-idempotent for
|
|
68
|
+
identical bodies, so the client retries `ConnectError` and transient gateway
|
|
69
|
+
statuses (`502`/`503`/`504`) on those paths without risking a second object.
|
|
70
|
+
Other POSTs (create/complete harvest) are not retried. Conflicting `409`
|
|
71
|
+
(same identifier, different content in one harvest) remains a real conflict
|
|
72
|
+
and is not treated as success.
|
|
@@ -69,6 +69,29 @@ class ApiClient:
|
|
|
69
69
|
_global_in_flight_requests: int = 0
|
|
70
70
|
_global_state_lock = threading.Lock()
|
|
71
71
|
|
|
72
|
+
@classmethod
|
|
73
|
+
def _is_idempotent_arc_post_path(cls, path: str) -> bool:
|
|
74
|
+
"""Return whether *path* is a server-idempotent ARC POST endpoint.
|
|
75
|
+
|
|
76
|
+
Covers ``POST /v3/arcs`` and ``POST /v3/harvests/{harvest_id}/arcs``.
|
|
77
|
+
Other POSTs (create/complete harvest) are not safe to retry.
|
|
78
|
+
"""
|
|
79
|
+
normalized = path.lstrip("/")
|
|
80
|
+
if normalized == "v3/arcs":
|
|
81
|
+
return True
|
|
82
|
+
parts = normalized.split("/")
|
|
83
|
+
harvest_arc_path_parts = 4 # v3 / harvests / {id} / arcs
|
|
84
|
+
return (
|
|
85
|
+
len(parts) == harvest_arc_path_parts and parts[0] == "v3" and parts[1] == "harvests" and parts[3] == "arcs"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def _is_retryable_method(cls, method: str, path: str) -> bool:
|
|
90
|
+
"""Return whether failures for this method/path may be retried."""
|
|
91
|
+
if method in cls._IDEMPOTENT_METHODS:
|
|
92
|
+
return True
|
|
93
|
+
return method == "POST" and cls._is_idempotent_arc_post_path(path)
|
|
94
|
+
|
|
72
95
|
@classmethod
|
|
73
96
|
def _configure_global_request_limiter(cls, max_concurrency: int) -> None:
|
|
74
97
|
"""Configure the package-wide request concurrency limiter."""
|
|
@@ -109,15 +132,15 @@ class ApiClient:
|
|
|
109
132
|
limiter.release()
|
|
110
133
|
|
|
111
134
|
@classmethod
|
|
112
|
-
def _should_retry_http_status(cls, method: str, status_code: int) -> bool:
|
|
113
|
-
"""Return whether a response status is retryable for a method."""
|
|
135
|
+
def _should_retry_http_status(cls, method: str, path: str, status_code: int) -> bool:
|
|
136
|
+
"""Return whether a response status is retryable for a method/path."""
|
|
114
137
|
transient = {httpx.codes.BAD_GATEWAY, httpx.codes.SERVICE_UNAVAILABLE, httpx.codes.GATEWAY_TIMEOUT}
|
|
115
|
-
return method
|
|
138
|
+
return cls._is_retryable_method(method, path) and status_code in transient
|
|
116
139
|
|
|
117
140
|
@classmethod
|
|
118
|
-
def _should_retry_request_error(cls, method: str, error: httpx.RequestError) -> bool:
|
|
119
|
-
"""Return whether a request error is retryable for a method."""
|
|
120
|
-
if
|
|
141
|
+
def _should_retry_request_error(cls, method: str, path: str, error: httpx.RequestError) -> bool:
|
|
142
|
+
"""Return whether a request error is retryable for a method/path."""
|
|
143
|
+
if not cls._is_retryable_method(method, path):
|
|
121
144
|
return False
|
|
122
145
|
return not isinstance(error, httpx.TimeoutException)
|
|
123
146
|
|
|
@@ -125,17 +148,35 @@ class ApiClient:
|
|
|
125
148
|
def _should_retry_failure(
|
|
126
149
|
cls,
|
|
127
150
|
method: str,
|
|
151
|
+
path: str,
|
|
128
152
|
*,
|
|
129
153
|
status_code: int | None = None,
|
|
130
154
|
request_error: httpx.RequestError | None = None,
|
|
131
155
|
) -> bool:
|
|
132
|
-
"""Return whether an HTTP failure is retryable for a request method."""
|
|
156
|
+
"""Return whether an HTTP failure is retryable for a request method/path."""
|
|
133
157
|
if status_code is not None:
|
|
134
|
-
return cls._should_retry_http_status(method, status_code)
|
|
158
|
+
return cls._should_retry_http_status(method, path, status_code)
|
|
135
159
|
if request_error is not None:
|
|
136
|
-
return cls._should_retry_request_error(method, request_error)
|
|
160
|
+
return cls._should_retry_request_error(method, path, request_error)
|
|
137
161
|
return False
|
|
138
162
|
|
|
163
|
+
@classmethod
|
|
164
|
+
def _format_request_error(cls, error: BaseException) -> str:
|
|
165
|
+
"""Return a non-empty description for a transport/request error.
|
|
166
|
+
|
|
167
|
+
``httpx.ConnectError`` (and similar) often stringify to ``""``, which
|
|
168
|
+
produces useless log lines like ``Request error: ``. Fall back to the
|
|
169
|
+
exception type name, then ``repr``.
|
|
170
|
+
"""
|
|
171
|
+
message = str(error).strip()
|
|
172
|
+
if message:
|
|
173
|
+
return message
|
|
174
|
+
type_name = type(error).__name__
|
|
175
|
+
representation = repr(error).strip()
|
|
176
|
+
if representation and representation != type_name:
|
|
177
|
+
return f"{type_name}: {representation}"
|
|
178
|
+
return type_name
|
|
179
|
+
|
|
139
180
|
@classmethod
|
|
140
181
|
def _build_failure_error_message(
|
|
141
182
|
cls,
|
|
@@ -151,9 +192,10 @@ class ApiClient:
|
|
|
151
192
|
return f"Request failed after {max_retries} retries: HTTP {status_code}", status_code
|
|
152
193
|
return cls._format_http_error_message(status_code, failure.response.text), status_code
|
|
153
194
|
|
|
195
|
+
detail = cls._format_request_error(failure)
|
|
154
196
|
if retryable:
|
|
155
|
-
return f"Request failed after {max_retries} retries: {
|
|
156
|
-
return f"Request failed: {
|
|
197
|
+
return f"Request failed after {max_retries} retries: {detail}", None
|
|
198
|
+
return f"Request failed: {detail}", None
|
|
157
199
|
|
|
158
200
|
@classmethod
|
|
159
201
|
def _should_retry_or_raise_failure(
|
|
@@ -161,6 +203,7 @@ class ApiClient:
|
|
|
161
203
|
failure: httpx.HTTPStatusError | httpx.RequestError,
|
|
162
204
|
*,
|
|
163
205
|
method: str,
|
|
206
|
+
path: str,
|
|
164
207
|
attempt: int,
|
|
165
208
|
max_retries: int,
|
|
166
209
|
) -> bool:
|
|
@@ -170,6 +213,7 @@ class ApiClient:
|
|
|
170
213
|
|
|
171
214
|
should_retry = cls._should_retry_failure(
|
|
172
215
|
method,
|
|
216
|
+
path,
|
|
173
217
|
status_code=status_code,
|
|
174
218
|
request_error=request_error,
|
|
175
219
|
)
|
|
@@ -178,7 +222,7 @@ class ApiClient:
|
|
|
178
222
|
if isinstance(failure, httpx.HTTPStatusError):
|
|
179
223
|
logger.warning("Transient HTTP error %d from server, will retry", failure.response.status_code)
|
|
180
224
|
else:
|
|
181
|
-
logger.warning("Request error: %s. Retrying...", failure)
|
|
225
|
+
logger.warning("Request error: %s. Retrying...", cls._format_request_error(failure))
|
|
182
226
|
return True
|
|
183
227
|
|
|
184
228
|
msg, normalized_status_code = cls._build_failure_error_message(
|
|
@@ -445,7 +489,7 @@ class ApiClient:
|
|
|
445
489
|
resp = await client.request(method, path, **kwargs)
|
|
446
490
|
|
|
447
491
|
# Retry on transient server-side errors before raising
|
|
448
|
-
should_retry = self._should_retry_failure(method, status_code=resp.status_code)
|
|
492
|
+
should_retry = self._should_retry_failure(method, path, status_code=resp.status_code)
|
|
449
493
|
if should_retry and attempt < self._config.max_retries:
|
|
450
494
|
logger.warning("Transient HTTP error %d from server, will retry", resp.status_code)
|
|
451
495
|
continue
|
|
@@ -459,6 +503,7 @@ class ApiClient:
|
|
|
459
503
|
if self._should_retry_or_raise_failure(
|
|
460
504
|
e,
|
|
461
505
|
method=method,
|
|
506
|
+
path=path,
|
|
462
507
|
attempt=attempt,
|
|
463
508
|
max_retries=self._config.max_retries,
|
|
464
509
|
):
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared helpers for ApiClient unit tests (not a pytest plugin)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from arctrl import ARC # type: ignore[import-untyped]
|
|
10
|
+
|
|
11
|
+
ARC_RESPONSE = {
|
|
12
|
+
"client_id": "test-client",
|
|
13
|
+
"message": "ARC processed successfully",
|
|
14
|
+
"arc_id": "arc-123",
|
|
15
|
+
"status": "created",
|
|
16
|
+
"metadata": {
|
|
17
|
+
"arc_hash": "abc123",
|
|
18
|
+
"status": "ACTIVE",
|
|
19
|
+
"first_seen": "2024-01-01T00:00:00Z",
|
|
20
|
+
"last_seen": "2024-01-01T00:00:00Z",
|
|
21
|
+
},
|
|
22
|
+
"events": [],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
HARVEST_RESPONSE: dict[str, str | None | dict] = {
|
|
26
|
+
"client_id": "test-client",
|
|
27
|
+
"message": "Harvest created",
|
|
28
|
+
"harvest_id": "harvest-456",
|
|
29
|
+
"rdi": "test-rdi",
|
|
30
|
+
"status": "RUNNING",
|
|
31
|
+
"started_at": "2024-01-01T00:00:00Z",
|
|
32
|
+
"completed_at": None,
|
|
33
|
+
"statistics": {},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
EXPECTED_ARC_UPLOADS = 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rocrate_dict(identifier: str = "mock-arc") -> dict[str, Any]:
|
|
40
|
+
"""Minimal valid RO-Crate payload for client tests."""
|
|
41
|
+
return {
|
|
42
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
43
|
+
"@graph": [{"@id": "./", "identifier": identifier}],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def arc_gen(*arcs: dict[str, Any] | str | ARC) -> AsyncGenerator[dict[str, Any] | str | ARC, None]:
|
|
48
|
+
"""Yield the provided arc dicts, JSON strings, or ARC objects as an async generator."""
|
|
49
|
+
for arc in arcs:
|
|
50
|
+
yield arc
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared fixtures for ApiClient unit tests."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from middleware.api_client import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def client_config(test_config_dict: dict) -> Config:
|
|
10
|
+
"""Create a Config instance for testing."""
|
|
11
|
+
return Config.from_data(test_config_dict)
|