fairagro-middleware-api-client 8.8.1.dev12__tar.gz → 9.0.1.dev13__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-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/PKG-INFO +1 -1
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/api_client.py +28 -20
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_client.py +155 -6
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/.gitignore +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/README.md +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/example_client_config.yaml +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/pyproject.toml +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/spec/harvest-client/design.md +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/spec/harvest-client/spec.md +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/__init__.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/config.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/models.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/py.typed +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/tests/conftest.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/tests/integration/conftest.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/tests/integration/test_create_arcs.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_api_client_config.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_client_config.py +0 -0
- {fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_retry_logic.py +0 -0
|
@@ -218,16 +218,21 @@ class ApiClient:
|
|
|
218
218
|
if status_code is None:
|
|
219
219
|
return True
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
221
|
+
# Only auth, permissions, and harvest-state errors are truly catastrophic:
|
|
222
|
+
# every subsequent ARC request would fail the same way, so there is no
|
|
223
|
+
# point continuing the harvest.
|
|
224
|
+
#
|
|
225
|
+
# All 5xx responses (500, 502, 503, 504, …) are treated as transient and
|
|
226
|
+
# recorded as SUBMISSION_FAILED instead. A server error on one ARC does
|
|
227
|
+
# not mean the next ARC will also fail — the API may recover mid-harvest.
|
|
228
|
+
# Connection errors (status_code is None) still abort the harvest because
|
|
229
|
+
# the API is fundamentally unreachable.
|
|
230
|
+
return status_code in {
|
|
231
|
+
HTTPStatus.UNAUTHORIZED,
|
|
232
|
+
HTTPStatus.FORBIDDEN,
|
|
233
|
+
HTTPStatus.NOT_FOUND,
|
|
234
|
+
HTTPStatus.CONFLICT,
|
|
235
|
+
}
|
|
231
236
|
|
|
232
237
|
async def _fail_harvest_safely(self, rdi: str, harvest_id: str) -> None:
|
|
233
238
|
"""Try marking a harvest as failed and suppress any secondary failures."""
|
|
@@ -251,6 +256,7 @@ class ApiClient:
|
|
|
251
256
|
) -> tuple[list[HarvestError], Exception | None]:
|
|
252
257
|
"""Return (errors, catastrophic_error) for completed submission tasks."""
|
|
253
258
|
errors: list[HarvestError] = []
|
|
259
|
+
catastrophic_error: Exception | None = None
|
|
254
260
|
|
|
255
261
|
for done_task in done_tasks:
|
|
256
262
|
arc_id = task_identifiers.pop(done_task, None)
|
|
@@ -258,18 +264,20 @@ class ApiClient:
|
|
|
258
264
|
done_task.result()
|
|
259
265
|
except Exception as e: # noqa: BLE001
|
|
260
266
|
if self._is_catastrophic_harvest_error(e):
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
267
|
+
if catastrophic_error is None:
|
|
268
|
+
catastrophic_error = e
|
|
269
|
+
else:
|
|
270
|
+
errors.append(
|
|
271
|
+
HarvestError(
|
|
272
|
+
arc_id=arc_id,
|
|
273
|
+
error_type=HarvestErrorType.SUBMISSION_FAILED,
|
|
274
|
+
message=str(e),
|
|
275
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
276
|
+
)
|
|
268
277
|
)
|
|
269
|
-
|
|
270
|
-
logger.warning("Skipping failed ARC submission in harvest %s: %s", harvest_id, e)
|
|
278
|
+
logger.warning("Skipping failed ARC submission in harvest %s: %s", harvest_id, e)
|
|
271
279
|
|
|
272
|
-
return errors,
|
|
280
|
+
return errors, catastrophic_error
|
|
273
281
|
|
|
274
282
|
async def _submit_arcs_parallel(
|
|
275
283
|
self,
|
|
@@ -13,7 +13,14 @@ import pytest
|
|
|
13
13
|
import respx
|
|
14
14
|
from arctrl import ARC, ArcInvestigation # type: ignore[import-untyped]
|
|
15
15
|
|
|
16
|
-
from middleware.api_client import
|
|
16
|
+
from middleware.api_client import (
|
|
17
|
+
ApiClient,
|
|
18
|
+
ApiClientError,
|
|
19
|
+
ArcResult,
|
|
20
|
+
Config,
|
|
21
|
+
HarvestErrorType,
|
|
22
|
+
HarvestResult,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
# ---------------------------------------------------------------------------
|
|
19
26
|
# Helpers
|
|
@@ -663,25 +670,34 @@ async def test_harvest_arcs_continues_on_item_error(client_config: Config) -> No
|
|
|
663
670
|
assert route_submit.call_count == EXPECTED_ARC_UPLOADS
|
|
664
671
|
assert complete_route.called
|
|
665
672
|
assert not cancel_route.called
|
|
673
|
+
assert len(result.errors) == 1
|
|
674
|
+
assert result.errors[0].error_type == HarvestErrorType.SUBMISSION_FAILED
|
|
675
|
+
assert result.errors[0].arc_id is None
|
|
676
|
+
assert "HTTP error 400" in result.errors[0].message
|
|
666
677
|
|
|
667
678
|
|
|
668
679
|
@pytest.mark.asyncio
|
|
669
680
|
@respx.mock
|
|
670
681
|
async def test_harvest_arcs_cancels_on_catastrophic_error(client_config: Config) -> None:
|
|
671
|
-
"""harvest_arcs marks the harvest as failed on catastrophic submission errors.
|
|
682
|
+
"""harvest_arcs marks the harvest as failed on catastrophic submission errors.
|
|
683
|
+
|
|
684
|
+
409 Conflict (harvest in wrong state) is catastrophic → the harvest is
|
|
685
|
+
immediately aborted and marked as failed. HTTP 500 is *not* catastrophic;
|
|
686
|
+
see test_harvest_arcs_500_is_submission_failed for that behaviour.
|
|
687
|
+
"""
|
|
672
688
|
failed_response = {**_HARVEST_RESPONSE, "status": "FAILED"}
|
|
673
689
|
respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
674
690
|
return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
|
|
675
691
|
)
|
|
676
692
|
respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
|
|
677
|
-
return_value=httpx.Response(http.HTTPStatus.
|
|
693
|
+
return_value=httpx.Response(http.HTTPStatus.CONFLICT, text="harvest already closed")
|
|
678
694
|
)
|
|
679
695
|
fail_route = respx.patch(f"{client_config.api_url}v3/harvests/harvest-456").mock(
|
|
680
696
|
return_value=httpx.Response(http.HTTPStatus.OK, json=failed_response)
|
|
681
697
|
)
|
|
682
698
|
|
|
683
699
|
async with ApiClient(client_config) as client:
|
|
684
|
-
with pytest.raises(ApiClientError):
|
|
700
|
+
with pytest.raises(ApiClientError, match="HTTP error 409"):
|
|
685
701
|
await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}))
|
|
686
702
|
|
|
687
703
|
assert fail_route.called
|
|
@@ -722,6 +738,10 @@ async def test_harvest_arcs_skips_duplicate_identifier(client_config: Config) ->
|
|
|
722
738
|
assert result.status == "COMPLETED"
|
|
723
739
|
assert arc_route.call_count == 1 # duplicate was skipped, not submitted
|
|
724
740
|
assert complete_route.called
|
|
741
|
+
assert len(result.errors) == 1
|
|
742
|
+
assert result.errors[0].error_type == HarvestErrorType.DUPLICATE
|
|
743
|
+
assert result.errors[0].arc_id == "duplicate-arc"
|
|
744
|
+
assert "duplicate-arc" in result.errors[0].message
|
|
725
745
|
|
|
726
746
|
|
|
727
747
|
@pytest.mark.asyncio
|
|
@@ -776,8 +796,9 @@ async def test_harvest_arcs_cancel_failure_does_not_mask_original_error(client_c
|
|
|
776
796
|
respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
777
797
|
return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
|
|
778
798
|
)
|
|
799
|
+
# 409 is catastrophic → triggers fail_harvest; 500 is NOT (see dedicated test).
|
|
779
800
|
respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
|
|
780
|
-
return_value=httpx.Response(http.HTTPStatus.
|
|
801
|
+
return_value=httpx.Response(http.HTTPStatus.CONFLICT, text="harvest already closed")
|
|
781
802
|
)
|
|
782
803
|
# Also make the fail call fail
|
|
783
804
|
respx.patch(f"{client_config.api_url}v3/harvests/harvest-456").mock(
|
|
@@ -785,5 +806,133 @@ async def test_harvest_arcs_cancel_failure_does_not_mask_original_error(client_c
|
|
|
785
806
|
)
|
|
786
807
|
|
|
787
808
|
async with ApiClient(client_config) as client:
|
|
788
|
-
with pytest.raises(ApiClientError, match="HTTP error
|
|
809
|
+
with pytest.raises(ApiClientError, match="HTTP error 409"):
|
|
789
810
|
await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}))
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@pytest.mark.asyncio
|
|
814
|
+
@respx.mock
|
|
815
|
+
async def test_harvest_arcs_all_exceptions_retrieved_when_multiple_tasks_catastrophic(
|
|
816
|
+
client_config: Config,
|
|
817
|
+
) -> None:
|
|
818
|
+
"""All task exceptions are retrieved when multiple tasks fail catastrophically.
|
|
819
|
+
|
|
820
|
+
Regression test for the "Task exception was never retrieved" asyncio warning
|
|
821
|
+
(confirmed in production: Task-1149, HTTP 500 for harvest arc submission).
|
|
822
|
+
|
|
823
|
+
Root cause: the old `_process_completed_arc_tasks` returned early after the
|
|
824
|
+
first catastrophic error, leaving the remaining done tasks' exceptions
|
|
825
|
+
unretrieved. asyncio then emitted a RuntimeWarning.
|
|
826
|
+
|
|
827
|
+
With max_concurrency=10 and 3 ARCs, all three tasks land in the same done
|
|
828
|
+
batch during the final drain (asyncio.wait). The early-return bug would leave
|
|
829
|
+
the 2nd and 3rd exceptions unretrieved. This test is run with
|
|
830
|
+
-W error::RuntimeWarning (see pyproject.toml) so any unretrieved exception
|
|
831
|
+
would immediately fail the test.
|
|
832
|
+
|
|
833
|
+
Note: 409 Conflict is used here because it is catastrophic (abort harvest).
|
|
834
|
+
HTTP 500 is *not* catastrophic since this fix — see
|
|
835
|
+
test_harvest_arcs_500_is_submission_failed for the non-catastrophic path.
|
|
836
|
+
The asyncio-warning fix (no early return in _process_completed_arc_tasks)
|
|
837
|
+
applies to both paths.
|
|
838
|
+
"""
|
|
839
|
+
respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
840
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
|
|
841
|
+
)
|
|
842
|
+
respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
|
|
843
|
+
return_value=httpx.Response(http.HTTPStatus.CONFLICT, text="harvest already closed")
|
|
844
|
+
)
|
|
845
|
+
fail_route = respx.patch(f"{client_config.api_url}v3/harvests/harvest-456").mock(
|
|
846
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json={**_HARVEST_RESPONSE, "status": "FAILED"})
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
# 3 ARCs all returning 409 (catastrophic). With max_concurrency=10, no
|
|
850
|
+
# intermediate wait fires — all three tasks accumulate in pending_tasks and
|
|
851
|
+
# are drained together. If _process_completed_arc_tasks returned early after
|
|
852
|
+
# the first catastrophic error, the remaining two exceptions would be
|
|
853
|
+
# unretrieved → RuntimeWarning (promoted to error by -W error).
|
|
854
|
+
async with ApiClient(client_config) as client:
|
|
855
|
+
with pytest.raises(ApiClientError, match="HTTP error 409"):
|
|
856
|
+
await client.harvest_arcs(
|
|
857
|
+
"test-rdi",
|
|
858
|
+
_arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"}),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
assert fail_route.called
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@pytest.mark.asyncio
|
|
865
|
+
@respx.mock
|
|
866
|
+
async def test_harvest_arcs_500_is_submission_failed(client_config: Config) -> None:
|
|
867
|
+
"""HTTP 5xx from ARC submission yields SUBMISSION_FAILED; the harvest continues.
|
|
868
|
+
|
|
869
|
+
All 5xx responses are treated as transient: a server error on one ARC does
|
|
870
|
+
not mean the next ARC will fail too. Only auth/state errors (401, 403, 404,
|
|
871
|
+
409) are truly catastrophic and abort the harvest.
|
|
872
|
+
"""
|
|
873
|
+
completed_response = {**_HARVEST_RESPONSE, "status": "COMPLETED", "completed_at": "2024-01-01T01:00:00Z"}
|
|
874
|
+
respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
875
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
|
|
876
|
+
)
|
|
877
|
+
# arc-1: 500, arc-2: success, arc-3: 500 → 2 SUBMISSION_FAILED errors
|
|
878
|
+
respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
|
|
879
|
+
side_effect=[
|
|
880
|
+
httpx.Response(http.HTTPStatus.INTERNAL_SERVER_ERROR, text="processing error"),
|
|
881
|
+
httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE),
|
|
882
|
+
httpx.Response(http.HTTPStatus.INTERNAL_SERVER_ERROR, text="processing error"),
|
|
883
|
+
]
|
|
884
|
+
)
|
|
885
|
+
fail_route = respx.patch(f"{client_config.api_url}v3/harvests/harvest-456").mock(
|
|
886
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json={**_HARVEST_RESPONSE, "status": "FAILED"})
|
|
887
|
+
)
|
|
888
|
+
complete_route = respx.post(f"{client_config.api_url}v3/harvests/harvest-456/complete").mock(
|
|
889
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json=completed_response)
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
async with ApiClient(client_config) as client:
|
|
893
|
+
result = await client.harvest_arcs(
|
|
894
|
+
"test-rdi",
|
|
895
|
+
_arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"}),
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
assert complete_route.called
|
|
899
|
+
assert not fail_route.called
|
|
900
|
+
assert len(result.errors) == 2 # noqa: PLR2004
|
|
901
|
+
assert all(e.error_type == HarvestErrorType.SUBMISSION_FAILED for e in result.errors)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@pytest.mark.asyncio
|
|
905
|
+
@respx.mock
|
|
906
|
+
async def test_harvest_arcs_502_is_submission_failed(client_config: Config) -> None:
|
|
907
|
+
"""HTTP 502/503/504 from ARC submission yields SUBMISSION_FAILED; harvest continues.
|
|
908
|
+
|
|
909
|
+
A gateway or service-unavailable error on one ARC does not mean the next
|
|
910
|
+
ARC will also fail — the API may recover mid-harvest. The harvest continues
|
|
911
|
+
and the failed ARC is recorded as SUBMISSION_FAILED rather than aborting.
|
|
912
|
+
"""
|
|
913
|
+
completed_response = {**_HARVEST_RESPONSE, "status": "COMPLETED", "completed_at": "2024-01-01T01:00:00Z"}
|
|
914
|
+
respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
915
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
|
|
916
|
+
)
|
|
917
|
+
# arc-1: 502, arc-2: success → 1 SUBMISSION_FAILED, harvest completes
|
|
918
|
+
respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
|
|
919
|
+
side_effect=[
|
|
920
|
+
httpx.Response(http.HTTPStatus.BAD_GATEWAY, text="gateway error"),
|
|
921
|
+
httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE),
|
|
922
|
+
]
|
|
923
|
+
)
|
|
924
|
+
fail_route = respx.patch(f"{client_config.api_url}v3/harvests/harvest-456").mock(
|
|
925
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json={**_HARVEST_RESPONSE, "status": "FAILED"})
|
|
926
|
+
)
|
|
927
|
+
complete_route = respx.post(f"{client_config.api_url}v3/harvests/harvest-456/complete").mock(
|
|
928
|
+
return_value=httpx.Response(http.HTTPStatus.OK, json=completed_response)
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
async with ApiClient(client_config) as client:
|
|
932
|
+
result = await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}, {"id": "arc-2"}))
|
|
933
|
+
|
|
934
|
+
assert complete_route.called
|
|
935
|
+
assert not fail_route.called
|
|
936
|
+
assert len(result.errors) == 1
|
|
937
|
+
assert result.errors[0].error_type == HarvestErrorType.SUBMISSION_FAILED
|
|
938
|
+
assert "502" in result.errors[0].message
|
{fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/.gitignore
RENAMED
|
File without changes
|
{fairagro_middleware_api_client-8.8.1.dev12 → fairagro_middleware_api_client-9.0.1.dev13}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|