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.
Files changed (23) hide show
  1. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/PKG-INFO +1 -1
  2. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/spec/harvest-client/design.md +15 -5
  3. {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
  4. fairagro_middleware_api_client-10.0.3.dev25/tests/client_test_support.py +50 -0
  5. fairagro_middleware_api_client-10.0.3.dev25/tests/unit/conftest.py +11 -0
  6. fairagro_middleware_api_client-10.0.3.dev25/tests/unit/test_client.py +587 -0
  7. fairagro_middleware_api_client-10.0.3.dev25/tests/unit/test_harvest_arcs.py +414 -0
  8. fairagro_middleware_api_client-10.0.2.dev24/tests/unit/test_client.py +0 -999
  9. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/.gitignore +0 -0
  10. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/README.md +0 -0
  11. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/example_client_config.yaml +0 -0
  12. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/pyproject.toml +0 -0
  13. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/spec/harvest-client/spec.md +0 -0
  14. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/__init__.py +0 -0
  15. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/config.py +0 -0
  16. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/models.py +0 -0
  17. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/src/middleware/api_client/py.typed +0 -0
  18. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/conftest.py +0 -0
  19. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/integration/conftest.py +0 -0
  20. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/integration/test_create_arcs.py +0 -0
  21. {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
  22. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/unit/test_client_config.py +0 -0
  23. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev25}/tests/unit/test_retry_logic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairagro-middleware-api-client
3
- Version: 10.0.2.dev24
3
+ Version: 10.0.3.dev25
4
4
  Summary: The FAIRagro advanced middleware API client
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.28.1
@@ -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
- Submitting both duplicates would cause the server to process two ARCs
52
- with the same identifier in the same harvest run, resulting in an opaque
53
- conflict. Client-side detection gives an explicit `DUPLICATE` error,
54
- prevents the wasted round-trip, and avoids requiring the server to handle
55
- intra-harvest identity conflicts.
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 in cls._IDEMPOTENT_METHODS and status_code in transient
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 method not in cls._IDEMPOTENT_METHODS:
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)