380 lines
11 KiB
Markdown
380 lines
11 KiB
Markdown
|
|
# Waiting Strategies and Utilities
|
||
|
|
|
||
|
|
Robust container readiness detection, log monitoring, and condition waiting utilities for reliable test execution across different container types and startup behaviors. Essential for ensuring containers are fully ready before test execution begins.
|
||
|
|
|
||
|
|
## Capabilities
|
||
|
|
|
||
|
|
### Container Readiness Decorator
|
||
|
|
|
||
|
|
Decorator for automatic retry logic when connecting to containers, handling transient errors and ensuring reliable container readiness detection.
|
||
|
|
|
||
|
|
```python { .api }
|
||
|
|
def wait_container_is_ready(*transient_exceptions: type[BaseException]) -> Callable:
|
||
|
|
"""
|
||
|
|
Decorator for container readiness checks with retry logic.
|
||
|
|
|
||
|
|
Automatically retries decorated function until success or timeout.
|
||
|
|
Handles common transient exceptions plus any additional specified exceptions.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
*transient_exceptions: Additional exception types to treat as transient
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Decorator function that wraps the target method
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
@wait_container_is_ready(CustomException)
|
||
|
|
def _connect(self):
|
||
|
|
# Connection logic that may fail transiently
|
||
|
|
pass
|
||
|
|
"""
|
||
|
|
```
|
||
|
|
|
||
|
|
### Log-Based Waiting
|
||
|
|
|
||
|
|
Wait for specific log output to appear in container logs, supporting both string patterns and custom predicates.
|
||
|
|
|
||
|
|
```python { .api }
|
||
|
|
def wait_for_logs(
|
||
|
|
container: DockerContainer,
|
||
|
|
predicate: Union[Callable[..., bool], str],
|
||
|
|
timeout: Union[float, None] = None,
|
||
|
|
interval: float = 1,
|
||
|
|
predicate_streams_and: bool = False,
|
||
|
|
raise_on_exit: bool = False
|
||
|
|
) -> float:
|
||
|
|
"""
|
||
|
|
Wait for specific log output from container.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
container: Container to monitor
|
||
|
|
predicate: String to search for or callable returning bool
|
||
|
|
timeout: Maximum wait time in seconds
|
||
|
|
interval: Polling interval in seconds
|
||
|
|
predicate_streams_and: Apply predicate to both stdout and stderr
|
||
|
|
raise_on_exit: Raise exception if container exits
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Time elapsed until condition was met
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
TimeoutError: If timeout reached without condition being met
|
||
|
|
ContainerStartException: If container exits unexpectedly
|
||
|
|
"""
|
||
|
|
```
|
||
|
|
|
||
|
|
### Generic Condition Waiting
|
||
|
|
|
||
|
|
Wait for arbitrary conditions to be met with configurable timeout and polling intervals.
|
||
|
|
|
||
|
|
```python { .api }
|
||
|
|
def wait_for(
|
||
|
|
condition: Callable[[], bool],
|
||
|
|
timeout: float = 120,
|
||
|
|
interval: float = 1
|
||
|
|
) -> bool:
|
||
|
|
"""
|
||
|
|
Wait for generic condition to be met.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
condition: Function returning True when condition is met
|
||
|
|
timeout: Maximum wait time in seconds
|
||
|
|
interval: Polling interval in seconds
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
True if condition was met, False if timeout reached
|
||
|
|
"""
|
||
|
|
```
|
||
|
|
|
||
|
|
## Configuration
|
||
|
|
|
||
|
|
### Global Timeout Settings
|
||
|
|
|
||
|
|
Container readiness waiting behavior is controlled by global configuration:
|
||
|
|
|
||
|
|
```python { .api }
|
||
|
|
from testcontainers.core.config import testcontainers_config
|
||
|
|
|
||
|
|
# Configure waiting behavior
|
||
|
|
testcontainers_config.max_tries: int = 120 # Maximum retry attempts
|
||
|
|
testcontainers_config.sleep_time: int = 1 # Sleep between retries (seconds)
|
||
|
|
testcontainers_config.timeout: int = 120 # Total timeout (seconds)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Transient Exceptions
|
||
|
|
|
||
|
|
Default transient exceptions that trigger automatic retries:
|
||
|
|
|
||
|
|
```python { .api }
|
||
|
|
TRANSIENT_EXCEPTIONS = (TimeoutError, ConnectionError)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Usage Examples
|
||
|
|
|
||
|
|
### Basic Log Waiting
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.core.container import DockerContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_for_logs
|
||
|
|
|
||
|
|
# Wait for application startup message
|
||
|
|
with DockerContainer("my-app:latest") as container:
|
||
|
|
# Wait for specific log message indicating readiness
|
||
|
|
delay = wait_for_logs(container, "Server started successfully")
|
||
|
|
print(f"Application ready after {delay:.2f} seconds")
|
||
|
|
|
||
|
|
# Now safe to connect to the application
|
||
|
|
app_port = container.get_exposed_port(8080)
|
||
|
|
# Make requests to the application...
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pattern Matching in Logs
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.core.container import DockerContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_for_logs
|
||
|
|
import re
|
||
|
|
|
||
|
|
with DockerContainer("postgres:13") as postgres:
|
||
|
|
postgres.with_env("POSTGRES_PASSWORD", "test")
|
||
|
|
|
||
|
|
# Wait for PostgreSQL to be ready using regex pattern
|
||
|
|
def postgres_ready(log_line):
|
||
|
|
return re.search(r"database system is ready to accept connections", log_line) is not None
|
||
|
|
|
||
|
|
delay = wait_for_logs(postgres, postgres_ready, timeout=30)
|
||
|
|
print(f"PostgreSQL ready after {delay:.2f} seconds")
|
||
|
|
```
|
||
|
|
|
||
|
|
### Custom Condition Waiting
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.redis import RedisContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_for
|
||
|
|
import redis
|
||
|
|
import time
|
||
|
|
|
||
|
|
with RedisContainer() as redis_container:
|
||
|
|
redis_client = redis_container.get_client()
|
||
|
|
|
||
|
|
# Wait for Redis to accept connections
|
||
|
|
def redis_ready():
|
||
|
|
try:
|
||
|
|
return redis_client.ping()
|
||
|
|
except:
|
||
|
|
return False
|
||
|
|
|
||
|
|
success = wait_for(redis_ready, timeout=30, interval=0.5)
|
||
|
|
if success:
|
||
|
|
print("Redis is ready for connections")
|
||
|
|
else:
|
||
|
|
print("Redis failed to become ready within timeout")
|
||
|
|
```
|
||
|
|
|
||
|
|
### HTTP Endpoint Waiting
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.core.container import DockerContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_for
|
||
|
|
import requests
|
||
|
|
|
||
|
|
with DockerContainer("nginx:alpine") as web_server:
|
||
|
|
web_server.with_exposed_ports(80)
|
||
|
|
|
||
|
|
host = web_server.get_container_host_ip()
|
||
|
|
port = web_server.get_exposed_port(80)
|
||
|
|
|
||
|
|
# Wait for HTTP endpoint to respond
|
||
|
|
def http_ready():
|
||
|
|
try:
|
||
|
|
response = requests.get(f"http://{host}:{port}/", timeout=1)
|
||
|
|
return response.status_code == 200
|
||
|
|
except:
|
||
|
|
return False
|
||
|
|
|
||
|
|
if wait_for(http_ready, timeout=60, interval=2):
|
||
|
|
print("Web server is responding to HTTP requests")
|
||
|
|
# Proceed with tests...
|
||
|
|
```
|
||
|
|
|
||
|
|
### Database Connection Waiting
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.postgres import PostgresContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_container_is_ready
|
||
|
|
import psycopg2
|
||
|
|
|
||
|
|
class CustomPostgresContainer(PostgresContainer):
|
||
|
|
@wait_container_is_ready(psycopg2.OperationalError)
|
||
|
|
def _connect(self):
|
||
|
|
"""Custom connection method with automatic retry."""
|
||
|
|
conn = psycopg2.connect(self.get_connection_url())
|
||
|
|
cursor = conn.cursor()
|
||
|
|
cursor.execute("SELECT 1")
|
||
|
|
cursor.fetchone()
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
# Use custom container with automatic connection retry
|
||
|
|
with CustomPostgresContainer("postgres:13") as postgres:
|
||
|
|
# Container automatically waits for successful connection
|
||
|
|
connection_url = postgres.get_connection_url()
|
||
|
|
print(f"PostgreSQL ready at: {connection_url}")
|
||
|
|
```
|
||
|
|
|
||
|
|
### Complex Readiness Checking
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.core.container import DockerContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_for_logs, wait_for
|
||
|
|
import requests
|
||
|
|
import time
|
||
|
|
|
||
|
|
class WebAppContainer(DockerContainer):
|
||
|
|
def __init__(self, image):
|
||
|
|
super().__init__(image)
|
||
|
|
self.with_exposed_ports(8080)
|
||
|
|
|
||
|
|
def wait_for_readiness(self):
|
||
|
|
"""Wait for multiple readiness conditions."""
|
||
|
|
# First, wait for application startup logs
|
||
|
|
wait_for_logs(self, "Application started", timeout=60)
|
||
|
|
|
||
|
|
# Then wait for health endpoint to respond
|
||
|
|
host = self.get_container_host_ip()
|
||
|
|
port = self.get_exposed_port(8080)
|
||
|
|
|
||
|
|
def health_check():
|
||
|
|
try:
|
||
|
|
response = requests.get(f"http://{host}:{port}/health", timeout=2)
|
||
|
|
return response.status_code == 200 and response.json().get("status") == "healthy"
|
||
|
|
except:
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not wait_for(health_check, timeout=30):
|
||
|
|
raise Exception("Application failed health check")
|
||
|
|
|
||
|
|
print("Application is fully ready")
|
||
|
|
|
||
|
|
# Use comprehensive readiness checking
|
||
|
|
with WebAppContainer("my-web-app:latest") as app:
|
||
|
|
app.wait_for_readiness()
|
||
|
|
# Application is now fully ready for testing
|
||
|
|
```
|
||
|
|
|
||
|
|
### Waiting with Custom Timeouts
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.core.container import DockerContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_for_logs
|
||
|
|
|
||
|
|
# Different containers may need different timeout strategies
|
||
|
|
containers = [
|
||
|
|
("redis:6", "Ready to accept connections", 15),
|
||
|
|
("postgres:13", "database system is ready", 45),
|
||
|
|
("elasticsearch:7.15.0", "started", 120)
|
||
|
|
]
|
||
|
|
|
||
|
|
for image, log_pattern, timeout in containers:
|
||
|
|
with DockerContainer(image) as container:
|
||
|
|
try:
|
||
|
|
delay = wait_for_logs(container, log_pattern, timeout=timeout)
|
||
|
|
print(f"{image} ready after {delay:.2f}s")
|
||
|
|
except TimeoutError:
|
||
|
|
print(f"{image} failed to start within {timeout}s")
|
||
|
|
# Handle timeout appropriately
|
||
|
|
```
|
||
|
|
|
||
|
|
### Parallel Container Startup
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.postgres import PostgresContainer
|
||
|
|
from testcontainers.redis import RedisContainer
|
||
|
|
from testcontainers.core.waiting_utils import wait_for
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
|
||
|
|
def start_and_wait(container, name):
|
||
|
|
"""Start container and wait for readiness."""
|
||
|
|
container.start()
|
||
|
|
|
||
|
|
# Different waiting strategies per container type
|
||
|
|
if isinstance(container, PostgresContainer):
|
||
|
|
def pg_ready():
|
||
|
|
try:
|
||
|
|
import psycopg2
|
||
|
|
conn = psycopg2.connect(container.get_connection_url())
|
||
|
|
conn.close()
|
||
|
|
return True
|
||
|
|
except:
|
||
|
|
return False
|
||
|
|
wait_for(pg_ready, timeout=45)
|
||
|
|
|
||
|
|
elif isinstance(container, RedisContainer):
|
||
|
|
client = container.get_client()
|
||
|
|
wait_for(lambda: client.ping(), timeout=15)
|
||
|
|
|
||
|
|
print(f"{name} is ready")
|
||
|
|
|
||
|
|
# Start containers in parallel
|
||
|
|
postgres = PostgresContainer("postgres:13")
|
||
|
|
redis = RedisContainer("redis:6")
|
||
|
|
|
||
|
|
threads = [
|
||
|
|
threading.Thread(target=start_and_wait, args=(postgres, "PostgreSQL")),
|
||
|
|
threading.Thread(target=start_and_wait, args=(redis, "Redis"))
|
||
|
|
]
|
||
|
|
|
||
|
|
start_time = time.time()
|
||
|
|
for thread in threads:
|
||
|
|
thread.start()
|
||
|
|
|
||
|
|
for thread in threads:
|
||
|
|
thread.join()
|
||
|
|
|
||
|
|
print(f"All containers ready in {time.time() - start_time:.2f} seconds")
|
||
|
|
|
||
|
|
# Clean up
|
||
|
|
postgres.stop()
|
||
|
|
redis.stop()
|
||
|
|
```
|
||
|
|
|
||
|
|
## Error Handling
|
||
|
|
|
||
|
|
### Common Exceptions
|
||
|
|
|
||
|
|
```python { .api }
|
||
|
|
from testcontainers.core.exceptions import (
|
||
|
|
ContainerStartException,
|
||
|
|
ContainerConnectException,
|
||
|
|
TimeoutError
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
with DockerContainer("problematic-image") as container:
|
||
|
|
wait_for_logs(container, "ready", timeout=30)
|
||
|
|
except ContainerStartException:
|
||
|
|
print("Container failed to start")
|
||
|
|
except TimeoutError:
|
||
|
|
print("Container did not become ready within timeout")
|
||
|
|
except ContainerConnectException:
|
||
|
|
print("Failed to connect to container")
|
||
|
|
```
|
||
|
|
|
||
|
|
### Graceful Timeout Handling
|
||
|
|
|
||
|
|
```python
|
||
|
|
from testcontainers.core.waiting_utils import wait_for
|
||
|
|
import logging
|
||
|
|
|
||
|
|
def wait_with_fallback(condition, primary_timeout=60, fallback_timeout=30):
|
||
|
|
"""Wait with fallback strategy."""
|
||
|
|
try:
|
||
|
|
if wait_for(condition, timeout=primary_timeout):
|
||
|
|
return True
|
||
|
|
else:
|
||
|
|
logging.warning(f"Primary wait timed out after {primary_timeout}s, trying fallback")
|
||
|
|
return wait_for(condition, timeout=fallback_timeout, interval=0.1)
|
||
|
|
except Exception as e:
|
||
|
|
logging.error(f"Wait failed: {e}")
|
||
|
|
return False
|
||
|
|
```
|