fairagro-middleware-api-client 9.0.0__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-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/PKG-INFO +1 -1
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/api_client.py +15 -10
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_client.py +139 -5
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/.gitignore +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/README.md +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/example_client_config.yaml +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/pyproject.toml +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/spec/harvest-client/design.md +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/spec/harvest-client/spec.md +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/__init__.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/config.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/models.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/py.typed +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/conftest.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/integration/conftest.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/integration/test_create_arcs.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_api_client_config.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_client_config.py +0 -0
- {fairagro_middleware_api_client-9.0.0 → 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."""
|
|
@@ -679,20 +679,25 @@ async def test_harvest_arcs_continues_on_item_error(client_config: Config) -> No
|
|
|
679
679
|
@pytest.mark.asyncio
|
|
680
680
|
@respx.mock
|
|
681
681
|
async def test_harvest_arcs_cancels_on_catastrophic_error(client_config: Config) -> None:
|
|
682
|
-
"""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
|
+
"""
|
|
683
688
|
failed_response = {**_HARVEST_RESPONSE, "status": "FAILED"}
|
|
684
689
|
respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
685
690
|
return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
|
|
686
691
|
)
|
|
687
692
|
respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
|
|
688
|
-
return_value=httpx.Response(http.HTTPStatus.
|
|
693
|
+
return_value=httpx.Response(http.HTTPStatus.CONFLICT, text="harvest already closed")
|
|
689
694
|
)
|
|
690
695
|
fail_route = respx.patch(f"{client_config.api_url}v3/harvests/harvest-456").mock(
|
|
691
696
|
return_value=httpx.Response(http.HTTPStatus.OK, json=failed_response)
|
|
692
697
|
)
|
|
693
698
|
|
|
694
699
|
async with ApiClient(client_config) as client:
|
|
695
|
-
with pytest.raises(ApiClientError):
|
|
700
|
+
with pytest.raises(ApiClientError, match="HTTP error 409"):
|
|
696
701
|
await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}))
|
|
697
702
|
|
|
698
703
|
assert fail_route.called
|
|
@@ -791,8 +796,9 @@ async def test_harvest_arcs_cancel_failure_does_not_mask_original_error(client_c
|
|
|
791
796
|
respx.post(f"{client_config.api_url}v3/harvests").mock(
|
|
792
797
|
return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
|
|
793
798
|
)
|
|
799
|
+
# 409 is catastrophic → triggers fail_harvest; 500 is NOT (see dedicated test).
|
|
794
800
|
respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
|
|
795
|
-
return_value=httpx.Response(http.HTTPStatus.
|
|
801
|
+
return_value=httpx.Response(http.HTTPStatus.CONFLICT, text="harvest already closed")
|
|
796
802
|
)
|
|
797
803
|
# Also make the fail call fail
|
|
798
804
|
respx.patch(f"{client_config.api_url}v3/harvests/harvest-456").mock(
|
|
@@ -800,5 +806,133 @@ async def test_harvest_arcs_cancel_failure_does_not_mask_original_error(client_c
|
|
|
800
806
|
)
|
|
801
807
|
|
|
802
808
|
async with ApiClient(client_config) as client:
|
|
803
|
-
with pytest.raises(ApiClientError, match="HTTP error
|
|
809
|
+
with pytest.raises(ApiClientError, match="HTTP error 409"):
|
|
804
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-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/.gitignore
RENAMED
|
File without changes
|
{fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
{fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/pyproject.toml
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
|