| """ |
| Reliability tests β repeated correctness, error handling, recovery |
| =================================================================== |
| |
| 3.1 Repeated identical requests β same status code every time |
| 3.2 Invalid-input handling β correct HTTP status codes for each bad-input class |
| 3.3 Recovery after a bad request β next valid request still works |
| 3.4 Health endpoint availability under sustained request fire |
| 3.5 Sustained load test β repeated lightweight calls with spacing |
| 3.6 Error-response contract consistency |
| """ |
| import json |
| import pathlib |
| import time |
| import statistics |
| import pytest |
| import requests |
| from tests.conftest import ( |
| BASE_URL, TIMEOUT, SEED_IMAGE_URL, |
| NONEXISTENT_REQUEST_ID, NONEXISTENT_USER_ID, |
| MINIMAL_GENERATE_PAYLOAD, |
| ) |
|
|
| ARTIFACTS = pathlib.Path(__file__).parent.parent / "artifacts" |
| ARTIFACTS.mkdir(exist_ok=True) |
|
|
| N_REPEAT = 4 |
| SPACING = 2 |
|
|
| _reliability_results: dict = {} |
|
|
|
|
| |
| |
| |
|
|
| @pytest.fixture(scope="session", autouse=True) |
| def _persist_reliability_metrics(): |
| """Yield during the test session; write metrics JSON afterwards.""" |
| yield |
| try: |
| (ARTIFACTS / "reliability_metrics.json").write_text( |
| json.dumps(_reliability_results, indent=2) |
| ) |
| except Exception as e: |
| print(f"Warning: could not save reliability metrics: {e}") |
|
|
|
|
| |
| |
| |
|
|
| def _get(session: requests.Session, url: str, **kw) -> requests.Response: |
| """GET with up to 3 retries on connection errors (500ms backoff each).""" |
| for attempt in range(3): |
| try: |
| return session.get(url, **kw) |
| except (requests.ConnectionError, requests.Timeout): |
| if attempt == 2: |
| raise |
| time.sleep(0.5 * (attempt + 1)) |
|
|
|
|
| def _post(session: requests.Session, url: str, **kw) -> requests.Response: |
| """POST with up to 3 retries on connection errors (500ms backoff each).""" |
| for attempt in range(3): |
| try: |
| return session.post(url, **kw) |
| except (requests.ConnectionError, requests.Timeout): |
| if attempt == 2: |
| raise |
| time.sleep(0.5 * (attempt + 1)) |
|
|
|
|
| |
| |
| |
|
|
| class TestRepeatedRequestConsistency: |
| """The same request should always return the same status code.""" |
|
|
| def test_health_always_returns_200(self, http): |
| statuses = [ |
| _get(http, f"{BASE_URL}/health", timeout=TIMEOUT).status_code |
| for _ in range(N_REPEAT) |
| ] |
| _reliability_results["repeated_health"] = { |
| "iterations": N_REPEAT, "statuses": statuses, |
| "consistent": len(set(statuses)) == 1, |
| } |
| assert all(s == 200 for s in statuses), ( |
| f"Health did not return 200 every time: {statuses}" |
| ) |
|
|
| def test_root_always_returns_200(self, http): |
| statuses = [ |
| _get(http, f"{BASE_URL}/", timeout=TIMEOUT).status_code |
| for _ in range(N_REPEAT) |
| ] |
| assert all(s == 200 for s in statuses), ( |
| f"Root did not return 200 every time: {statuses}" |
| ) |
|
|
| def test_unknown_job_always_returns_same_code(self, http): |
| url = f"{BASE_URL}/jobs/{NONEXISTENT_REQUEST_ID}/status" |
| statuses = [_get(http, url, timeout=TIMEOUT).status_code for _ in range(N_REPEAT)] |
| _reliability_results["repeated_job_status"] = { |
| "iterations": N_REPEAT, "statuses": statuses, |
| "consistent": len(set(statuses)) == 1, |
| } |
| |
| assert len(set(statuses)) == 1, ( |
| f"Inconsistent status codes for same unknown job: {statuses}" |
| ) |
|
|
| def test_user_jobs_always_returns_200(self, http): |
| url = f"{BASE_URL}/jobs/user/{NONEXISTENT_USER_ID}" |
| statuses = [_get(http, url, timeout=TIMEOUT).status_code for _ in range(N_REPEAT)] |
| assert all(s == 200 for s in statuses), ( |
| f"User-jobs did not return 200 every time: {statuses}" |
| ) |
|
|
| def test_422_always_returned_for_missing_request_id(self, http): |
| statuses = [ |
| _post(http, f"{BASE_URL}/generate/pdf", json={}, timeout=TIMEOUT).status_code |
| for _ in range(N_REPEAT) |
| ] |
| assert all(s == 422 for s in statuses), ( |
| f"Schema validation should always give 422, got: {statuses}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class TestInvalidInputHandling: |
| """Each class of bad input should yield a predictable HTTP code.""" |
|
|
| CASES = [ |
| |
| ( |
| "missing_request_id_pdf", |
| f"{BASE_URL}/generate/pdf", "POST", |
| {"seed_images": [SEED_IMAGE_URL]}, |
| [422], |
| ), |
| ( |
| "missing_request_id_async", |
| f"{BASE_URL}/generate/async", "POST", |
| {"seed_images": [SEED_IMAGE_URL]}, |
| [422], |
| ), |
| ( |
| "empty_seed_images_pdf", |
| f"{BASE_URL}/generate/pdf", "POST", |
| {**MINIMAL_GENERATE_PAYLOAD, "seed_images": []}, |
| [422], |
| ), |
| ( |
| "num_solutions_zero_pdf", |
| f"{BASE_URL}/generate/pdf", "POST", |
| {**MINIMAL_GENERATE_PAYLOAD, |
| "prompt_params": {**MINIMAL_GENERATE_PAYLOAD["prompt_params"], "num_solutions": 0}}, |
| [422], |
| ), |
| ( |
| "non_int_user_id", |
| f"{BASE_URL}/jobs/user/abc", "GET", |
| None, |
| [422], |
| ), |
| ( |
| "nonexistent_job_status", |
| f"{BASE_URL}/jobs/{NONEXISTENT_REQUEST_ID}/status", "GET", |
| None, |
| [404, 500], |
| ), |
| ( |
| "nonexistent_request_id_pdf", |
| f"{BASE_URL}/generate/pdf", "POST", |
| MINIMAL_GENERATE_PAYLOAD, |
| [404], |
| ), |
| ] |
|
|
| @pytest.mark.parametrize("description,url,method,payload,expected", CASES) |
| def test_case(self, http, description, url, method, payload, expected): |
| if method == "POST": |
| r = _post(http, url, json=payload, timeout=TIMEOUT) |
| else: |
| r = _get(http, url, timeout=TIMEOUT) |
|
|
| _reliability_results.setdefault("invalid_input_cases", {})[description] = { |
| "status_code": r.status_code, |
| "allowed": expected, |
| "ok": r.status_code in expected, |
| } |
| assert r.status_code in expected, ( |
| f"[{description}] Expected {expected}, got {r.status_code}: {r.text[:200]}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class TestRecoveryAfterBadRequest: |
| """A valid request after a bad request should not be contaminated.""" |
|
|
| def test_health_recovers_after_bad_generate_pdf(self, http): |
| |
| _post(http, f"{BASE_URL}/generate/pdf", json={}, timeout=TIMEOUT) |
| |
| r = _get(http, f"{BASE_URL}/health", timeout=TIMEOUT) |
| assert r.status_code == 200 |
|
|
| def test_user_jobs_recovers_after_bad_job_status(self, http): |
| |
| _get(http, f"{BASE_URL}/jobs/{NONEXISTENT_REQUEST_ID}/status", timeout=TIMEOUT) |
| |
| url = f"{BASE_URL}/jobs/user/{NONEXISTENT_USER_ID}" |
| r = _get(http, url, timeout=TIMEOUT) |
| assert r.status_code == 200 |
|
|
| def test_sequential_mixed_valid_invalid(self, http): |
| """Interleave valid and invalid requests β valid ones must always succeed.""" |
| for i in range(4): |
| |
| if i % 2 == 0: |
| _post(http, f"{BASE_URL}/generate/async", json={}, timeout=TIMEOUT) |
| else: |
| r = _get(http, f"{BASE_URL}/health", timeout=TIMEOUT) |
| assert r.status_code == 200, ( |
| f"Health failed after a bad request at iteration {i}" |
| ) |
|
|
| _reliability_results["recovery"] = {"passed": True} |
|
|
|
|
| |
| |
| |
|
|
| class TestHealthAvailabilityUnderLoad: |
| """Health endpoint must remain available while other calls are in-flight.""" |
|
|
| def test_health_available_during_job_status_calls(self, http): |
| health_codes = [] |
| for _ in range(3): |
| |
| _get(http, f"{BASE_URL}/jobs/{NONEXISTENT_REQUEST_ID}/status", timeout=TIMEOUT) |
| time.sleep(0.5) |
| |
| r = _get(http, f"{BASE_URL}/health", timeout=TIMEOUT) |
| health_codes.append(r.status_code) |
|
|
| _reliability_results["health_under_load"] = { |
| "health_pings": len(health_codes), |
| "health_200s": health_codes.count(200), |
| } |
| assert all(c == 200 for c in health_codes), ( |
| f"Health endpoint returned non-200 while load was happening: {health_codes}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class TestSustainedLoad: |
| """ |
| Fire N sequential /health calls with a small spacing. |
| Measures stability: no degradation in response time over time. |
| """ |
|
|
| def test_sustained_health_calls(self, http): |
| n = 6 |
| samples = [] |
| ok = 0 |
| wall_start = time.perf_counter() |
|
|
| for i in range(n): |
| t0 = time.perf_counter() |
| r = _get(http, f"{BASE_URL}/health", timeout=TIMEOUT) |
| elapsed = time.perf_counter() - t0 |
| samples.append(elapsed) |
| if r.status_code == 200: |
| ok += 1 |
| if i < n - 1: |
| time.sleep(SPACING) |
|
|
| wall = time.perf_counter() - wall_start |
| result = { |
| "iterations": n, "ok": ok, "fail": n - ok, |
| "success_rate": round(ok / n, 4), |
| "min_s": round(min(samples), 3), |
| "mean_s": round(statistics.mean(samples), 3), |
| "max_s": round(max(samples), 3), |
| "stdev_s": round(statistics.stdev(samples), 3) if n > 1 else 0, |
| "wall_s": round(wall, 3), |
| } |
| _reliability_results["sustained_load"] = result |
| print(f"\n Sustained load β {result}") |
|
|
| assert ok == n, f"Expected all {n} requests to succeed, got {ok}" |
| assert result["success_rate"] == 1.0 |
|
|
|
|
| |
| |
| |
|
|
| class TestErrorResponseContract: |
| """Error responses must always be JSON with a 'detail' field. |
| |
| NOTE: A 3-second cooldown is applied at the start of each test so the |
| HuggingFace Space can recover after the sustained-load tests above. |
| """ |
|
|
| @pytest.fixture(autouse=True) |
| def _cooldown(self): |
| """Give the Space 3s to recover after sustained-load tests.""" |
| time.sleep(3) |
|
|
| def test_422_has_detail_list(self, http): |
| r = _post(http, f"{BASE_URL}/generate/pdf", json={}, timeout=TIMEOUT) |
| assert r.status_code == 422 |
| body = r.json() |
| assert "detail" in body |
| |
| assert isinstance(body["detail"], list) |
| for err in body["detail"]: |
| assert "loc" in err, f"Validation error missing 'loc': {err}" |
| assert "msg" in err, f"Validation error missing 'msg': {err}" |
| assert "type" in err, f"Validation error missing 'type': {err}" |
|
|
| def test_404_has_detail_string(self, http): |
| r = _post( |
| http, f"{BASE_URL}/generate/pdf", |
| json=MINIMAL_GENERATE_PAYLOAD, |
| timeout=TIMEOUT, |
| ) |
| if r.status_code == 404: |
| body = r.json() |
| assert "detail" in body |
| assert isinstance(body["detail"], str) |
|
|
| def test_503_has_detail_if_redis_unavailable(self, http): |
| r = _post( |
| http, f"{BASE_URL}/generate/async", |
| json=MINIMAL_GENERATE_PAYLOAD, |
| timeout=TIMEOUT, |
| ) |
| if r.status_code == 503: |
| body = r.json() |
| assert "detail" in body |
|
|
| def test_repeated_422_response_is_stable(self, http): |
| """The structure of 422 responses must be identical across calls.""" |
| responses = [ |
| _post(http, f"{BASE_URL}/generate/pdf", json={}, timeout=TIMEOUT).json() |
| for _ in range(3) |
| ] |
| |
| for body in responses: |
| assert "detail" in body |
| assert isinstance(body["detail"], list) |
|
|