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.
Files changed (23) hide show
  1. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/PKG-INFO +1 -1
  2. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/spec/harvest-client/design.md +15 -5
  3. {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
  4. fairagro_middleware_api_client-10.0.3.dev26/tests/client_test_support.py +50 -0
  5. fairagro_middleware_api_client-10.0.3.dev26/tests/unit/conftest.py +11 -0
  6. fairagro_middleware_api_client-10.0.3.dev26/tests/unit/test_client.py +607 -0
  7. fairagro_middleware_api_client-10.0.3.dev26/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.dev26}/.gitignore +0 -0
  10. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/README.md +0 -0
  11. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/example_client_config.yaml +0 -0
  12. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/pyproject.toml +0 -0
  13. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/spec/harvest-client/spec.md +0 -0
  14. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/__init__.py +0 -0
  15. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/config.py +0 -0
  16. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/models.py +0 -0
  17. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/src/middleware/api_client/py.typed +0 -0
  18. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/conftest.py +0 -0
  19. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/integration/conftest.py +0 -0
  20. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/integration/test_create_arcs.py +0 -0
  21. {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
  22. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/tests/unit/test_client_config.py +0 -0
  23. {fairagro_middleware_api_client-10.0.2.dev24 → fairagro_middleware_api_client-10.0.3.dev26}/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.dev26
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,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: {failure}", None
156
- return f"Request failed: {failure}", None
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)