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.
Files changed (19) hide show
  1. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/PKG-INFO +1 -1
  2. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/api_client.py +15 -10
  3. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_client.py +139 -5
  4. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/.gitignore +0 -0
  5. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/README.md +0 -0
  6. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/example_client_config.yaml +0 -0
  7. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/pyproject.toml +0 -0
  8. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/spec/harvest-client/design.md +0 -0
  9. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/spec/harvest-client/spec.md +0 -0
  10. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/__init__.py +0 -0
  11. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/config.py +0 -0
  12. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/models.py +0 -0
  13. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/src/middleware/api_client/py.typed +0 -0
  14. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/conftest.py +0 -0
  15. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/integration/conftest.py +0 -0
  16. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/integration/test_create_arcs.py +0 -0
  17. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_api_client_config.py +0 -0
  18. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/tests/unit/test_client_config.py +0 -0
  19. {fairagro_middleware_api_client-9.0.0 → fairagro_middleware_api_client-9.0.1.dev13}/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: 9.0.0
3
+ Version: 9.0.1.dev13
4
4
  Summary: The FAIRagro advanced middleware API client
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.28.1
@@ -218,16 +218,21 @@ class ApiClient:
218
218
  if status_code is None:
219
219
  return True
220
220
 
221
- return (
222
- status_code
223
- in {
224
- HTTPStatus.UNAUTHORIZED,
225
- HTTPStatus.FORBIDDEN,
226
- HTTPStatus.NOT_FOUND,
227
- HTTPStatus.CONFLICT,
228
- }
229
- or status_code >= HTTPStatus.INTERNAL_SERVER_ERROR
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.INTERNAL_SERVER_ERROR, text="server unavailable")
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.INTERNAL_SERVER_ERROR, text="arc error")
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 500"):
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