diff --git a/pyproject.toml b/pyproject.toml index ce8cc1d..8df1e1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ requires-python = ">=3.13" dependencies = [ "black>=26.3.1", "coverage>=7.13.5", - "isort>=8.0.1", "loguru>=0.7.3", "prometheus-client>=0.24.1", "pydantic>=2.12.5", @@ -27,7 +26,7 @@ requires = ["uv_build == 0.10.12"] build-backend = "uv_build" [project.scripts] -python-response-time = "python_response_time.main:run_app" +python-response-time = "python_response_time.main:main" checks = "python_response_time.pre_flight:run_checks" [tool.semantic_release] @@ -56,7 +55,9 @@ exclude = ["__pycache__", "build", "dist", ".venv"] [tool.ruff.lint] select = ["E", "F", "W", "C", "B", "I", "N", "D", "UP", "T", "A"] -ignore = [] [tool.ruff.lint.per-file-ignores] "tests/*" = ["F401"] + +[tool.ruff.format] +quote-style = "double" \ No newline at end of file diff --git a/src/python_response_time/core/__init__.py b/src/python_response_time/core/__init__.py index da9eba2..3ff8ba2 100644 --- a/src/python_response_time/core/__init__.py +++ b/src/python_response_time/core/__init__.py @@ -7,6 +7,7 @@ REQUEST_LATENCY, start_metrics_server, ) +from python_response_time.core.startup import register_signals, sleep_interruptible __all__ = [ "app_settings", @@ -14,4 +15,6 @@ "start_metrics_server", "REQUEST_COUNT", "REQUEST_LATENCY", + "register_signals", + "sleep_interruptible", ] diff --git a/src/python_response_time/core/config.py b/src/python_response_time/core/config.py index 5cda777..6539862 100644 --- a/src/python_response_time/core/config.py +++ b/src/python_response_time/core/config.py @@ -15,12 +15,12 @@ class Settings(BaseSettings): NUM_REQUESTS: Annotated[ int, Field(gt=0, le=1_000_000, description="Total number of requests") ] = 10 - CONCURRENCY: Annotated[ - int, Field(gt=0, le=10_000, description="Concurrent requests") - ] = 1 - TIMEOUT: Annotated[ + CONNECT_TIMEOUT: Annotated[ + float, Field(gt=0, le=120, description="Connection timeout in seconds") + ] = 1.0 + READ_TIMEOUT: Annotated[ float, Field(gt=0, le=120, description="Request timeout in seconds") - ] = 10.0 + ] = 3.0 REQUEST_DELAY: Annotated[ float, Field(gt=0, le=60, description="Delay between requests in seconds") ] = 0.1 diff --git a/src/python_response_time/core/startup.py b/src/python_response_time/core/startup.py new file mode 100644 index 0000000..b0eba25 --- /dev/null +++ b/src/python_response_time/core/startup.py @@ -0,0 +1,66 @@ +"""Startup utilities: signal handling and interruptible sleep (K8s-safe).""" + +import signal +import time +from functools import partial +from threading import Event +from types import FrameType + +from loguru import logger +from rich.console import Console + + +def handle_shutdown( + signum: int, + frame: FrameType | None, + shutdown_event: Event, + console: Console, +) -> None: + """Handle SIGTERM / SIGINT (idempotent).""" + if shutdown_event.is_set(): + return + shutdown_event.set() + console.print("\n[yellow]Shutdown signal received... stopping gracefully[/yellow]") + logger.warning( + { + "event": "shutdown", + "signal": signum, + } + ) + logger.debug(f"Signal frame: {frame}") + + +def _handler( + signum: int, + frame: FrameType | None, + shutdown_event: Event, + console: Console, +) -> None: + """Thin wrapper to adapt signal handler signature.""" + handle_shutdown(signum, frame, shutdown_event, console) + + +def register_signals(shutdown_event: Event, console: Console) -> None: + """Register signal handlers for graceful shutdown.""" + handler = partial( + _handler, + shutdown_event=shutdown_event, + console=console, + ) + + signal.signal(signal.SIGTERM, handler) + signal.signal(signal.SIGINT, handler) + + +def sleep_interruptible(seconds: float, shutdown_event: Event) -> None: + """Sleep in small increments, exiting early if shutdown is triggered. + + K8s-safe: ensures fast shutdown (<100ms granularity) + """ + step = 0.05 + end = time.monotonic() + seconds + + while time.monotonic() < end: + if shutdown_event.is_set(): + return + time.sleep(step) diff --git a/src/python_response_time/main.py b/src/python_response_time/main.py index 8c0e5f1..5efb570 100644 --- a/src/python_response_time/main.py +++ b/src/python_response_time/main.py @@ -1,129 +1,127 @@ -"""Python Response Time Benchmark (production-safe, Docker/K8s friendly).""" +"""Run the Python Response Time Benchmark (K8s-safe, single-threaded).""" -import signal import threading import time +from threading import Event -import requests from loguru import logger +from requests import Session +from requests.exceptions import ConnectTimeout, ReadTimeout, RequestException, SSLError from rich.console import Console from python_response_time.core import ( REQUEST_COUNT, REQUEST_LATENCY, app_settings, + register_signals, setup_logger, + sleep_interruptible, start_metrics_server, ) -setup_logger(app_settings.LOG_LEVEL) -console = Console() -shutdown_event = threading.Event() - - -def handle_shutdown(*_): - """Handle SIGTERM / SIGINT.""" - shutdown_event.set() - console.print("\n[yellow]Shutdown signal received... stopping gracefully[/yellow]") - - -def register_signals(): - """Register signal handlers for graceful shutdown.""" - signal.signal(signal.SIGTERM, handle_shutdown) - signal.signal(signal.SIGINT, handle_shutdown) - - -def sleep_interruptible(seconds: float): - """Sleep in small increments, checking for shutdown signal.""" - step = 0.1 - elapsed = 0.0 - - while elapsed < seconds: - if shutdown_event.is_set(): - return - try: - time.sleep(step) - except KeyboardInterrupt: - shutdown_event.set() - return - elapsed += step - - -def run_app(): - """Run the main application logic.""" +def run_app(console: Console, shutdown_event: Event) -> None: + """Run the main benchmark loop.""" console.print("[bold cyan]HTTP Benchmark Starting...[/bold cyan]") console.print(f"Target: {app_settings.TARGET_URL}") console.print(f"Requests: {app_settings.NUM_REQUESTS}") - console.print(f"Concurrency: {app_settings.CONCURRENCY}") - console.print(f"Timeout: {app_settings.TIMEOUT}s") console.print(f"Delay: {app_settings.REQUEST_DELAY}s") console.print(f"SSL Verify: {app_settings.VERIFY_SSL}\n") - session = requests.Session() + session = Session() try: for i in range(app_settings.NUM_REQUESTS): if shutdown_event.is_set(): break + request_id = i + 1 + logger.info({"event": "request_start", "request": request_id}) start_time = time.perf_counter() try: response = session.get( str(app_settings.TARGET_URL), - timeout=app_settings.TIMEOUT, + timeout=( + app_settings.CONNECT_TIMEOUT, + app_settings.READ_TIMEOUT, + ), verify=app_settings.VERIFY_SSL, ) elapsed = time.perf_counter() - start_time console.print( - f"{i + 1:>4} | {response.status_code} | {elapsed * 1000:.2f} ms" + f"{request_id:>4} | " + f"{response.status_code} | " + f"{elapsed * 1000:.2f} ms" ) logger.info( { - "request": i + 1, + "event": "request_complete", + "request": request_id, "status": response.status_code, "response_time_ms": elapsed * 1000, - "url": str(app_settings.TARGET_URL), } ) REQUEST_COUNT.labels(status=str(response.status_code)).inc() REQUEST_LATENCY.labels(status=str(response.status_code)).observe( elapsed ) - except requests.exceptions.SSLError as e: - console.print(f"{i + 1:>4} | SSL ERROR") + except ConnectTimeout: + console.print(f"{request_id:>4} | CONNECT_TIMEOUT") + logger.warning( + { + "event": "connect_timeout", + "request": request_id, + } + ) + REQUEST_COUNT.labels(status="connect_timeout").inc() + except ReadTimeout: + console.print(f"{request_id:>4} | READ_TIMEOUT") + logger.warning( + { + "event": "read_timeout", + "request": request_id, + } + ) + REQUEST_COUNT.labels(status="read_timeout").inc() + except SSLError as e: + console.print(f"{request_id:>4} | SSL_ERROR") logger.error( { - "request": i + 1, - "error": "ssl_error", + "event": "ssl_error", + "request": request_id, "details": str(e), } ) - except requests.RequestException as e: - REQUEST_COUNT.labels(status="error").inc() - console.print(f"{i + 1:>4} | ERROR") + REQUEST_COUNT.labels(status="ssl_error").inc() + except RequestException as e: + console.print(f"{request_id:>4} | ERROR") logger.error( { - "request": i + 1, - "error": "request_error", + "event": "request_error", + "request": request_id, "details": str(e), } ) + REQUEST_COUNT.labels(status="error").inc() if app_settings.REQUEST_DELAY > 0: - sleep_interruptible(app_settings.REQUEST_DELAY) + sleep_interruptible(app_settings.REQUEST_DELAY, shutdown_event) finally: session.close() console.print("\n[green]Benchmark stopped gracefully[/green]") -if __name__ == "__main__": - """Run the application.""" - register_signals() +def main() -> None: + """Application entrypoint.""" + setup_logger(app_settings.LOG_LEVEL) + console = Console() + shutdown_event = threading.Event() + register_signals(shutdown_event, console) start_metrics_server(port=8000) try: - run_app() - except KeyboardInterrupt: - shutdown_event.set() - console.print("\n[yellow]Interrupted (Ctrl+C). Exiting cleanly...[/yellow]") + run_app(console, shutdown_event) finally: shutdown_event.set() console.print("[green]Cleanup complete[/green]") + + +if __name__ == "__main__": + main() diff --git a/src/python_response_time/pre_flight.py b/src/python_response_time/pre_flight.py index 70f5951..0d78288 100644 --- a/src/python_response_time/pre_flight.py +++ b/src/python_response_time/pre_flight.py @@ -21,17 +21,16 @@ def _run(cmd: list[str]) -> None: def run_checks() -> None: - """Run ruff, isort, black and tests (coverage+pytest). + """Run ruff, black, and tests (coverage+pytest). - This is intended to be invoked via the project script entrypoint, e.g.: - `uv run checks` or `python -m python_response_time.pre_flight run_checks` - when installed. + Intended usage: + `uv run checks` """ py = sys.executable - _run([py, "-m", "ruff", "check", ".", "--fix", "--exit-zero"]) - _run([py, "-m", "isort", "."]) + + _run([py, "-m", "ruff", "check", ".", "--fix"]) _run([py, "-m", "black", "."]) - _run([py, "-m", "ruff", "check", ".", "--exit-zero"]) + _run([py, "-m", "ruff", "check", "."]) _run([py, "-m", "coverage", "run", "-m", "pytest"]) logger.info("All checks completed.") diff --git a/tests/test_basic.py b/tests/test_basic.py index ef99272..e25f5da 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -18,20 +18,6 @@ def test_num_requests(): assert 0 < n <= 1_000_000 -def test_concurrency(): - """CONCURRENCY should be an int > 0 and <= 10_000.""" - c = app_settings.CONCURRENCY - assert isinstance(c, int) - assert 0 < c <= 10_000 - - -def test_timeout(): - """TIMEOUT should be a float > 0 and <= 120.""" - t = app_settings.TIMEOUT - assert isinstance(t, float) - assert 0 < t <= 120 - - def test_log_level(): """LOG_LEVEL should be a valid log level string.""" level = app_settings.LOG_LEVEL diff --git a/uv.lock b/uv.lock index 3b1a819..5b10b31 100644 --- a/uv.lock +++ b/uv.lock @@ -221,15 +221,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "isort" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, -] - [[package]] name = "loguru" version = "0.7.3" @@ -441,7 +432,6 @@ source = { editable = "." } dependencies = [ { name = "black" }, { name = "coverage" }, - { name = "isort" }, { name = "loguru" }, { name = "prometheus-client" }, { name = "pydantic" }, @@ -462,7 +452,6 @@ build = [ requires-dist = [ { name = "black", specifier = ">=26.3.1" }, { name = "coverage", specifier = ">=7.13.5" }, - { name = "isort", specifier = ">=8.0.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "prometheus-client", specifier = ">=0.24.1" }, { name = "pydantic", specifier = ">=2.12.5" },