fairagro-middleware-api-client 10.0.2.dev24__tar.gz → 10.0.3.dev25__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.dev25}/PKG-INFO +1 -1
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/spec/harvest-client/design.md +15 -5
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/api_client.py +37 -10
- fairagro_middleware_api_client-10.0.3.dev25/tests/client_test_support.py +50 -0
- fairagro_middleware_api_client-10.0.3.dev25/tests/unit/conftest.py +11 -0
- fairagro_middleware_api_client-10.0.3.dev25/tests/unit/test_client.py +587 -0
- fairagro_middleware_api_client-10.0.3.dev25/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.dev25}/.gitignore +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/README.md +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/example_client_config.yaml +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/pyproject.toml +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/spec/harvest-client/spec.md +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/__init__.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/config.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/models.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/py.typed +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/conftest.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/integration/conftest.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/integration/test_create_arcs.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/unit/test_api_client_config.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/unit/test_client_config.py +0 -0
- {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/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,15 +148,16 @@ 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
|
|
|
139
163
|
@classmethod
|
|
@@ -161,6 +185,7 @@ class ApiClient:
|
|
|
161
185
|
failure: httpx.HTTPStatusError | httpx.RequestError,
|
|
162
186
|
*,
|
|
163
187
|
method: str,
|
|
188
|
+
path: str,
|
|
164
189
|
attempt: int,
|
|
165
190
|
max_retries: int,
|
|
166
191
|
) -> bool:
|
|
@@ -170,6 +195,7 @@ class ApiClient:
|
|
|
170
195
|
|
|
171
196
|
should_retry = cls._should_retry_failure(
|
|
172
197
|
method,
|
|
198
|
+
path,
|
|
173
199
|
status_code=status_code,
|
|
174
200
|
request_error=request_error,
|
|
175
201
|
)
|
|
@@ -445,7 +471,7 @@ class ApiClient:
|
|
|
445
471
|
resp = await client.request(method, path, **kwargs)
|
|
446
472
|
|
|
447
473
|
# Retry on transient server-side errors before raising
|
|
448
|
-
should_retry = self._should_retry_failure(method, status_code=resp.status_code)
|
|
474
|
+
should_retry = self._should_retry_failure(method, path, status_code=resp.status_code)
|
|
449
475
|
if should_retry and attempt < self._config.max_retries:
|
|
450
476
|
logger.warning("Transient HTTP error %d from server, will retry", resp.status_code)
|
|
451
477
|
continue
|
|
@@ -459,6 +485,7 @@ class ApiClient:
|
|
|
459
485
|
if self._should_retry_or_raise_failure(
|
|
460
486
|
e,
|
|
461
487
|
method=method,
|
|
488
|
+
path=path,
|
|
462
489
|
attempt=attempt,
|
|
463
490
|
max_retries=self._config.max_retries,
|
|
464
491
|
):
|
|
@@ -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)
|