import pytest import httpx import respx from src.fivetran_client import ( FIVETRAN_API_BASE, FivetranClient, RATE_LIMIT_MAX_RETRIES, _error_detail, ) CONN_ID = "conn_test_abc" CONNECTION_URL = f"{FIVETRAN_API_BASE}/connections/{CONN_ID}" _CONNECTED_PAYLOAD = { "code": "Success", "data": { "id": CONN_ID, "service": "shopify", "schema": "shopify_test", "status": { "setup_state": "connected", "sync_state": "scheduled", }, "config": {"shop": "test.myshopify.com"}, }, } @pytest.fixture def client(): with FivetranClient("key", "secret") as c: yield c class TestRetryBehavior: @respx.mock def test_429_is_retried_and_succeeds_on_second_attempt(self, mocker, client): mocker.patch("time.sleep") route = respx.get(CONNECTION_URL).mock( side_effect=[ httpx.Response(429, headers={"Retry-After": "1"}, json={"code": "RateLimit"}), httpx.Response(200, json=_CONNECTED_PAYLOAD), ] ) result = client.get_connection_status(CONN_ID) assert result.id == CONN_ID assert route.call_count == 2 @respx.mock def test_429_honours_retry_after_header(self, mocker, client): sleep_mock = mocker.patch("time.sleep") respx.get(CONNECTION_URL).mock( side_effect=[ httpx.Response(429, headers={"Retry-After": "42"}, json={"code": "RateLimit"}), httpx.Response(200, json=_CONNECTED_PAYLOAD), ] ) client.get_connection_status(CONN_ID) sleep_mock.assert_called_once_with(42) @respx.mock def test_429_on_post_is_also_retried(self, mocker, client): mocker.patch("time.sleep") sync_url = f"{FIVETRAN_API_BASE}/connections/{CONN_ID}/sync" respx.post(sync_url).mock( side_effect=[ httpx.Response(429, json={"code": "RateLimit"}), httpx.Response(200, json={"code": "Success", "data": {}}), ] ) client.trigger_sync(CONN_ID) @respx.mock def test_5xx_on_get_is_retried(self, mocker, client): mocker.patch("time.sleep") route = respx.get(CONNECTION_URL).mock( side_effect=[ httpx.Response(500, json={"code": "ServerError"}), httpx.Response(200, json=_CONNECTED_PAYLOAD), ] ) result = client.get_connection_status(CONN_ID) assert result.id == CONN_ID assert route.call_count == 2 @respx.mock def test_5xx_on_post_is_not_retried(self, mocker, client): mocker.patch("time.sleep") sync_url = f"{FIVETRAN_API_BASE}/connections/{CONN_ID}/sync" route = respx.post(sync_url).mock(return_value=httpx.Response(500, json={"code": "ServerError"})) with pytest.raises(httpx.HTTPStatusError): client.trigger_sync(CONN_ID) assert route.call_count == 1 @respx.mock def test_max_retries_exhausted_raises(self, mocker, client): mocker.patch("time.sleep") respx.get(CONNECTION_URL).mock(return_value=httpx.Response(429, json={"code": "RateLimit"})) with pytest.raises(httpx.HTTPStatusError): client.get_connection_status(CONN_ID) @respx.mock def test_retry_count_matches_max_retries_plus_initial_attempt(self, mocker, client): mocker.patch("time.sleep") route = respx.get(CONNECTION_URL).mock(return_value=httpx.Response(429, json={"code": "RateLimit"})) with pytest.raises(httpx.HTTPStatusError): client.get_connection_status(CONN_ID) assert route.call_count == RATE_LIMIT_MAX_RETRIES + 1 @respx.mock def test_4xx_non_429_is_not_retried(self, mocker, client): mocker.patch("time.sleep") route = respx.get(CONNECTION_URL).mock(return_value=httpx.Response(403, json={"code": "Forbidden"})) with pytest.raises(httpx.HTTPStatusError): client.get_connection_status(CONN_ID) assert route.call_count == 1 class TestErrorDetail: def test_json_with_code_and_message(self): resp = httpx.Response(429, json={"code": "RateLimit", "message": "Too many requests"}) assert _error_detail(resp) == "RateLimit: Too many requests" def test_json_with_code_only(self): resp = httpx.Response(500, json={"code": "InternalError"}) assert _error_detail(resp) == "InternalError" def test_json_without_code_or_message(self): resp = httpx.Response(500, json={"error": "something"}) assert _error_detail(resp) == "" def test_non_json_body_suppressed(self): resp = httpx.Response(500, content=b"Internal Server Error") result = _error_detail(resp) assert "bytes" in result assert "non-JSON" in result assert "Internal Server Error" not in result def test_empty_json_object(self): resp = httpx.Response(500, json={}) assert _error_detail(resp) == ""