Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand Down Expand Up @@ -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"
3 changes: 3 additions & 0 deletions src/python_response_time/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
REQUEST_LATENCY,
start_metrics_server,
)
from python_response_time.core.startup import register_signals, sleep_interruptible

__all__ = [
"app_settings",
"setup_logger",
"start_metrics_server",
"REQUEST_COUNT",
"REQUEST_LATENCY",
"register_signals",
"sleep_interruptible",
]
10 changes: 5 additions & 5 deletions src/python_response_time/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions src/python_response_time/core/startup.py
Original file line number Diff line number Diff line change
@@ -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)
122 changes: 60 additions & 62 deletions src/python_response_time/main.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 6 additions & 7 deletions src/python_response_time/pre_flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
14 changes: 0 additions & 14 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 0 additions & 11 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading