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.
Files changed (20) hide show
  1. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/PKG-INFO +2 -2
  2. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/pyproject.toml +0 -3
  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
  4. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_client.py +72 -10
  5. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/.gitignore +0 -0
  6. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/README.md +0 -0
  7. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/example_client_config.yaml +0 -0
  8. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/__init__.py +0 -0
  9. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/config.py +0 -0
  10. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/models.py +0 -0
  11. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/src/middleware/api_client/py.typed +0 -0
  12. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/client_test_support.py +0 -0
  13. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/conftest.py +0 -0
  14. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/integration/conftest.py +0 -0
  15. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/integration/test_create_arcs.py +0 -0
  16. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/conftest.py +0 -0
  17. {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
  18. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_client_config.py +0 -0
  19. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_harvest_arcs.py +0 -0
  20. {fairagro_middleware_api_client-11.1.1.dev34 → fairagro_middleware_api_client-12.0.1.dev35}/tests/unit/test_retry_logic.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: fairagro-middleware-api-client
3
- Version: 11.1.1.dev34
3
+ Version: 12.0.1.dev35
4
4
  Summary: The FAIRagro advanced middleware API client
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.28.1
@@ -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
- from collections.abc import AsyncGenerator, AsyncIterator
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 _is_retryable_method(cls, method: str, path: str) -> bool:
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._is_idempotent_arc_post_path(path)
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(cls, method: str, path: str, status_code: int) -> bool:
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(cls, method: str, path: str, error: httpx.RequestError) -> bool:
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
- method: str,
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
- method=method,
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
- data = await self._post("v3/harvests", request)
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 test_create_harvest_network_error_not_retried(client_config: Config) -> None:
256
- """Non-ARC POSTs (create harvest) must not retry ConnectError."""
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.create_harvest(rdi="test-rdi")
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="Request failed: ConnectError") as exc_info:
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 test_create_harvest_503_not_retried(client_config: Config) -> None:
469
- """POST create_harvest is not retried on transient server errors."""
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
- return_value=httpx.Response(http.HTTPStatus.SERVICE_UNAVAILABLE, text="Busy")
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
- with pytest.raises(ApiClientError, match="HTTP error 503"):
475
- await client.create_harvest(rdi="test-rdi")
476
- assert route.call_count == 1
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