From cd2926790adc2eeb1efd7ba83ed6a319de07af48 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 31 Mar 2026 12:04:45 -0400 Subject: [PATCH 1/2] chore: relax uv, fix unleash e2e test stability Signed-off-by: Todd Baert --- .../tests/test_integration.py | 174 +++++++++++------- pyproject.toml | 2 +- 2 files changed, 113 insertions(+), 63 deletions(-) diff --git a/providers/openfeature-provider-unleash/tests/test_integration.py b/providers/openfeature-provider-unleash/tests/test_integration.py index f328ab59..c27b568d 100644 --- a/providers/openfeature-provider-unleash/tests/test_integration.py +++ b/providers/openfeature-provider-unleash/tests/test_integration.py @@ -299,15 +299,100 @@ def wait_for_flags_visible(timeout=30, interval=2): def postgres_container(): """Create and start PostgreSQL container.""" with PostgresContainer("postgres:15", driver=None) as postgres: - postgres.start() postgres_url = postgres.get_connection_url() logger.info(f"PostgreSQL started at: {postgres_url}") yield postgres +def _get_container_health_url(container): + """Get the health check URL for the container. + + Raises if port is not exposed yet. + """ + exposed_port = container.get_exposed_port(4242) + return f"http://localhost:{exposed_port}" + + +def _check_container_not_dead(container): + """Check if container is still running, raise if dead. + + Raises RuntimeError with logs if container exited or is dead. + """ + docker_container = container.get_wrapped_container() + if docker_container: + docker_container.reload() + if docker_container.status in ("exited", "dead"): + logs = docker_container.logs().decode(errors="replace") + raise RuntimeError( + f"Unleash container died ({docker_container.status}).\nLogs:\n{logs}" + ) + + +def _get_container_logs(container): + """Get container logs for debugging.""" + docker_container = container.get_wrapped_container() + if docker_container: + docker_container.reload() + return docker_container.logs().decode(errors="replace") + return "" + + +def _log_timeout_and_logs(container): + """Log timeout error and container logs for debugging.""" + try: + logs = _get_container_logs(container) + logger.error("Unleash container did not become healthy within timeout") + if logs: + logger.error(f"Logs:\n{logs}") + except Exception: + logger.exception("Failed to retrieve container logs") + + +def _wait_for_healthy(container, max_wait_time=120): + """Poll the Unleash container until its /health endpoint returns 200. + + Returns the base URL on success, raises on timeout or container death. + """ + start_time = time.time() + + while time.time() - start_time < max_wait_time: + try: + try: + unleash_url = _get_container_health_url(container) + logger.info(f"Trying health check at: {unleash_url}") + except Exception as port_error: + _check_container_not_dead(container) + logger.error(f"Port not ready yet: {port_error}") + time.sleep(2) + continue + + response = requests.get(f"{unleash_url}/health", timeout=5) + if response.status_code == 200: + logger.info("Unleash container is healthy!") + return unleash_url + + logger.error(f"Health check failed, status: {response.status_code}") + time.sleep(2) + + except RuntimeError: + raise + except Exception as e: + logger.error(f"Health check error: {e}") + time.sleep(2) + + _log_timeout_and_logs(container) + raise RuntimeError("Unleash container did not become healthy within timeout") + + +# Unleash's migration runner can hit a pg_class_relname_nsp_index race +# condition that kills the process on first start. Retrying is safe because +# the partially-created objects already exist on the second attempt. +MAX_UNLEASH_ATTEMPTS = 3 + + @pytest.fixture(scope="session") -def unleash_container(postgres_container): # noqa: PLR0915 +def unleash_container(postgres_container): """Create and start Unleash container with PostgreSQL dependency.""" global UNLEASH_URL @@ -322,75 +407,40 @@ def unleash_container(postgres_container): # noqa: PLR0915 f":{exposed_port}", ":5432" ) - unleash = UnleashContainer(internal_url) + last_error = None - with unleash as container: - logger.info("Starting Unleash container...") - container.start() - logger.info("Unleash container started") + for attempt in range(1, MAX_UNLEASH_ATTEMPTS + 1): + unleash = UnleashContainer(internal_url) - # Wait for health check to pass - logger.info("Waiting for Unleash container to be healthy...") - max_wait_time = 120 # 2 minutes; Unleash DB migrations can be slow in CI - start_time = time.time() + with unleash as container: + logger.info(f"Starting Unleash container (attempt {attempt})...") + container.start() + logger.info("Unleash container started") - while time.time() - start_time < max_wait_time: try: - try: - exposed_port = container.get_exposed_port(4242) - unleash_url = f"http://localhost:{exposed_port}" - logger.info(f"Trying health check at: {unleash_url}") - except Exception as port_error: - # if the container exited, fail fast with its logs - docker_container = container.get_wrapped_container() - if docker_container: - docker_container.reload() - if docker_container.status in ("exited", "dead"): - logs = docker_container.logs().decode(errors="replace") - raise RuntimeError( - f"Unleash container died ({docker_container.status}).\n" - f"Logs:\n{logs}" - ) from port_error - logger.error(f"Port not ready yet: {port_error}") - time.sleep(2) + unleash_url = _wait_for_healthy(container) + except RuntimeError as exc: + last_error = exc + if "pg_class_relname_nsp_index" in str(exc) or "died" in str(exc): + logger.warning( + f"Unleash failed on attempt {attempt} " + f"(likely migration race); retrying..." + ) continue - - response = requests.get(f"{unleash_url}/health", timeout=5) - if response.status_code == 200: - logger.info("Unleash container is healthy!") - break - - logger.error(f"Health check failed, status: {response.status_code}") - time.sleep(2) - - except RuntimeError: raise - except Exception as e: - logger.error(f"Health check error: {e}") - time.sleep(2) - else: - # timeout; dump container logs for debugging - try: - docker_container = container.get_wrapped_container() - if docker_container: - docker_container.reload() - logs = docker_container.logs().decode(errors="replace") - logger.error( - f"Unleash container status: {docker_container.status}\n" - f"Logs:\n{logs}" - ) - except Exception: - logger.exception("Failed to retrieve container logs") - raise Exception("Unleash container did not become healthy within timeout") - # Get the exposed port and set global URL - UNLEASH_URL = f"http://localhost:{container.get_exposed_port(4242)}" - logger.info(f"Unleash started at: {unleash_url}") + UNLEASH_URL = unleash_url + logger.info(f"Unleash started at: {unleash_url}") + + insert_admin_token(postgres_container) + logger.info("Admin token inserted into database") - insert_admin_token(postgres_container) - logger.info("Admin token inserted into database") + yield container, unleash_url + return - yield container, unleash_url + raise RuntimeError( + f"Unleash failed to start after {MAX_UNLEASH_ATTEMPTS} attempts" + ) from last_error @pytest.fixture(scope="session", autouse=True) diff --git a/pyproject.toml b/pyproject.toml index be595fa4..f0836ef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dev = [ ] [tool.uv] -required-version = "~=0.10.0" +required-version = ">=0.10.0" package = false [tool.uv.sources] From 29bad68de3262a41c67c837ccafa458fc61c474f Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 31 Mar 2026 14:33:55 -0400 Subject: [PATCH 2/2] Update providers/openfeature-provider-unleash/tests/test_integration.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Todd Baert --- providers/openfeature-provider-unleash/tests/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/providers/openfeature-provider-unleash/tests/test_integration.py b/providers/openfeature-provider-unleash/tests/test_integration.py index c27b568d..9aafbabf 100644 --- a/providers/openfeature-provider-unleash/tests/test_integration.py +++ b/providers/openfeature-provider-unleash/tests/test_integration.py @@ -414,7 +414,6 @@ def unleash_container(postgres_container): with unleash as container: logger.info(f"Starting Unleash container (attempt {attempt})...") - container.start() logger.info("Unleash container started") try: