Skip to content

Commit 7145e83

Browse files
authored
feat: implement sig handling & interruptible sleep; update app entrypoint/config settings
feat: implement sig handling & interruptible sleep; update app entrypoint/config settings
2 parents 4dfe71e + 6673833 commit 7145e83

8 files changed

Lines changed: 144 additions & 102 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ requires-python = ">=3.13"
77
dependencies = [
88
"black>=26.3.1",
99
"coverage>=7.13.5",
10-
"isort>=8.0.1",
1110
"loguru>=0.7.3",
1211
"prometheus-client>=0.24.1",
1312
"pydantic>=2.12.5",
@@ -27,7 +26,7 @@ requires = ["uv_build == 0.10.12"]
2726
build-backend = "uv_build"
2827

2928
[project.scripts]
30-
python-response-time = "python_response_time.main:run_app"
29+
python-response-time = "python_response_time.main:main"
3130
checks = "python_response_time.pre_flight:run_checks"
3231

3332
[tool.semantic_release]
@@ -56,7 +55,9 @@ exclude = ["__pycache__", "build", "dist", ".venv"]
5655

5756
[tool.ruff.lint]
5857
select = ["E", "F", "W", "C", "B", "I", "N", "D", "UP", "T", "A"]
59-
ignore = []
6058

6159
[tool.ruff.lint.per-file-ignores]
6260
"tests/*" = ["F401"]
61+
62+
[tool.ruff.format]
63+
quote-style = "double"

src/python_response_time/core/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
REQUEST_LATENCY,
88
start_metrics_server,
99
)
10+
from python_response_time.core.startup import register_signals, sleep_interruptible
1011

1112
__all__ = [
1213
"app_settings",
1314
"setup_logger",
1415
"start_metrics_server",
1516
"REQUEST_COUNT",
1617
"REQUEST_LATENCY",
18+
"register_signals",
19+
"sleep_interruptible",
1720
]

src/python_response_time/core/config.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ class Settings(BaseSettings):
1515
NUM_REQUESTS: Annotated[
1616
int, Field(gt=0, le=1_000_000, description="Total number of requests")
1717
] = 10
18-
CONCURRENCY: Annotated[
19-
int, Field(gt=0, le=10_000, description="Concurrent requests")
20-
] = 1
21-
TIMEOUT: Annotated[
18+
CONNECT_TIMEOUT: Annotated[
19+
float, Field(gt=0, le=120, description="Connection timeout in seconds")
20+
] = 1.0
21+
READ_TIMEOUT: Annotated[
2222
float, Field(gt=0, le=120, description="Request timeout in seconds")
23-
] = 10.0
23+
] = 3.0
2424
REQUEST_DELAY: Annotated[
2525
float, Field(gt=0, le=60, description="Delay between requests in seconds")
2626
] = 0.1
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Startup utilities: signal handling and interruptible sleep (K8s-safe)."""
2+
3+
import signal
4+
import time
5+
from functools import partial
6+
from threading import Event
7+
from types import FrameType
8+
9+
from loguru import logger
10+
from rich.console import Console
11+
12+
13+
def handle_shutdown(
14+
signum: int,
15+
frame: FrameType | None,
16+
shutdown_event: Event,
17+
console: Console,
18+
) -> None:
19+
"""Handle SIGTERM / SIGINT (idempotent)."""
20+
if shutdown_event.is_set():
21+
return
22+
shutdown_event.set()
23+
console.print("\n[yellow]Shutdown signal received... stopping gracefully[/yellow]")
24+
logger.warning(
25+
{
26+
"event": "shutdown",
27+
"signal": signum,
28+
}
29+
)
30+
logger.debug(f"Signal frame: {frame}")
31+
32+
33+
def _handler(
34+
signum: int,
35+
frame: FrameType | None,
36+
shutdown_event: Event,
37+
console: Console,
38+
) -> None:
39+
"""Thin wrapper to adapt signal handler signature."""
40+
handle_shutdown(signum, frame, shutdown_event, console)
41+
42+
43+
def register_signals(shutdown_event: Event, console: Console) -> None:
44+
"""Register signal handlers for graceful shutdown."""
45+
handler = partial(
46+
_handler,
47+
shutdown_event=shutdown_event,
48+
console=console,
49+
)
50+
51+
signal.signal(signal.SIGTERM, handler)
52+
signal.signal(signal.SIGINT, handler)
53+
54+
55+
def sleep_interruptible(seconds: float, shutdown_event: Event) -> None:
56+
"""Sleep in small increments, exiting early if shutdown is triggered.
57+
58+
K8s-safe: ensures fast shutdown (<100ms granularity)
59+
"""
60+
step = 0.05
61+
end = time.monotonic() + seconds
62+
63+
while time.monotonic() < end:
64+
if shutdown_event.is_set():
65+
return
66+
time.sleep(step)

src/python_response_time/main.py

Lines changed: 60 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,127 @@
1-
"""Python Response Time Benchmark (production-safe, Docker/K8s friendly)."""
1+
"""Run the Python Response Time Benchmark (K8s-safe, single-threaded)."""
22

3-
import signal
43
import threading
54
import time
5+
from threading import Event
66

7-
import requests
87
from loguru import logger
8+
from requests import Session
9+
from requests.exceptions import ConnectTimeout, ReadTimeout, RequestException, SSLError
910
from rich.console import Console
1011

1112
from python_response_time.core import (
1213
REQUEST_COUNT,
1314
REQUEST_LATENCY,
1415
app_settings,
16+
register_signals,
1517
setup_logger,
18+
sleep_interruptible,
1619
start_metrics_server,
1720
)
1821

19-
setup_logger(app_settings.LOG_LEVEL)
20-
console = Console()
2122

22-
shutdown_event = threading.Event()
23-
24-
25-
def handle_shutdown(*_):
26-
"""Handle SIGTERM / SIGINT."""
27-
shutdown_event.set()
28-
console.print("\n[yellow]Shutdown signal received... stopping gracefully[/yellow]")
29-
30-
31-
def register_signals():
32-
"""Register signal handlers for graceful shutdown."""
33-
signal.signal(signal.SIGTERM, handle_shutdown)
34-
signal.signal(signal.SIGINT, handle_shutdown)
35-
36-
37-
def sleep_interruptible(seconds: float):
38-
"""Sleep in small increments, checking for shutdown signal."""
39-
step = 0.1
40-
elapsed = 0.0
41-
42-
while elapsed < seconds:
43-
if shutdown_event.is_set():
44-
return
45-
try:
46-
time.sleep(step)
47-
except KeyboardInterrupt:
48-
shutdown_event.set()
49-
return
50-
elapsed += step
51-
52-
53-
def run_app():
54-
"""Run the main application logic."""
23+
def run_app(console: Console, shutdown_event: Event) -> None:
24+
"""Run the main benchmark loop."""
5525
console.print("[bold cyan]HTTP Benchmark Starting...[/bold cyan]")
5626
console.print(f"Target: {app_settings.TARGET_URL}")
5727
console.print(f"Requests: {app_settings.NUM_REQUESTS}")
58-
console.print(f"Concurrency: {app_settings.CONCURRENCY}")
59-
console.print(f"Timeout: {app_settings.TIMEOUT}s")
6028
console.print(f"Delay: {app_settings.REQUEST_DELAY}s")
6129
console.print(f"SSL Verify: {app_settings.VERIFY_SSL}\n")
6230

63-
session = requests.Session()
31+
session = Session()
6432

6533
try:
6634
for i in range(app_settings.NUM_REQUESTS):
6735
if shutdown_event.is_set():
6836
break
37+
request_id = i + 1
38+
logger.info({"event": "request_start", "request": request_id})
6939
start_time = time.perf_counter()
7040
try:
7141
response = session.get(
7242
str(app_settings.TARGET_URL),
73-
timeout=app_settings.TIMEOUT,
43+
timeout=(
44+
app_settings.CONNECT_TIMEOUT,
45+
app_settings.READ_TIMEOUT,
46+
),
7447
verify=app_settings.VERIFY_SSL,
7548
)
7649
elapsed = time.perf_counter() - start_time
7750
console.print(
78-
f"{i + 1:>4} | {response.status_code} | {elapsed * 1000:.2f} ms"
51+
f"{request_id:>4} | "
52+
f"{response.status_code} | "
53+
f"{elapsed * 1000:.2f} ms"
7954
)
8055
logger.info(
8156
{
82-
"request": i + 1,
57+
"event": "request_complete",
58+
"request": request_id,
8359
"status": response.status_code,
8460
"response_time_ms": elapsed * 1000,
85-
"url": str(app_settings.TARGET_URL),
8661
}
8762
)
8863
REQUEST_COUNT.labels(status=str(response.status_code)).inc()
8964
REQUEST_LATENCY.labels(status=str(response.status_code)).observe(
9065
elapsed
9166
)
92-
except requests.exceptions.SSLError as e:
93-
console.print(f"{i + 1:>4} | SSL ERROR")
67+
except ConnectTimeout:
68+
console.print(f"{request_id:>4} | CONNECT_TIMEOUT")
69+
logger.warning(
70+
{
71+
"event": "connect_timeout",
72+
"request": request_id,
73+
}
74+
)
75+
REQUEST_COUNT.labels(status="connect_timeout").inc()
76+
except ReadTimeout:
77+
console.print(f"{request_id:>4} | READ_TIMEOUT")
78+
logger.warning(
79+
{
80+
"event": "read_timeout",
81+
"request": request_id,
82+
}
83+
)
84+
REQUEST_COUNT.labels(status="read_timeout").inc()
85+
except SSLError as e:
86+
console.print(f"{request_id:>4} | SSL_ERROR")
9487
logger.error(
9588
{
96-
"request": i + 1,
97-
"error": "ssl_error",
89+
"event": "ssl_error",
90+
"request": request_id,
9891
"details": str(e),
9992
}
10093
)
101-
except requests.RequestException as e:
102-
REQUEST_COUNT.labels(status="error").inc()
103-
console.print(f"{i + 1:>4} | ERROR")
94+
REQUEST_COUNT.labels(status="ssl_error").inc()
95+
except RequestException as e:
96+
console.print(f"{request_id:>4} | ERROR")
10497
logger.error(
10598
{
106-
"request": i + 1,
107-
"error": "request_error",
99+
"event": "request_error",
100+
"request": request_id,
108101
"details": str(e),
109102
}
110103
)
104+
REQUEST_COUNT.labels(status="error").inc()
111105
if app_settings.REQUEST_DELAY > 0:
112-
sleep_interruptible(app_settings.REQUEST_DELAY)
106+
sleep_interruptible(app_settings.REQUEST_DELAY, shutdown_event)
113107
finally:
114108
session.close()
115109
console.print("\n[green]Benchmark stopped gracefully[/green]")
116110

117111

118-
if __name__ == "__main__":
119-
"""Run the application."""
120-
register_signals()
112+
def main() -> None:
113+
"""Application entrypoint."""
114+
setup_logger(app_settings.LOG_LEVEL)
115+
console = Console()
116+
shutdown_event = threading.Event()
117+
register_signals(shutdown_event, console)
121118
start_metrics_server(port=8000)
122119
try:
123-
run_app()
124-
except KeyboardInterrupt:
125-
shutdown_event.set()
126-
console.print("\n[yellow]Interrupted (Ctrl+C). Exiting cleanly...[/yellow]")
120+
run_app(console, shutdown_event)
127121
finally:
128122
shutdown_event.set()
129123
console.print("[green]Cleanup complete[/green]")
124+
125+
126+
if __name__ == "__main__":
127+
main()

src/python_response_time/pre_flight.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,16 @@ def _run(cmd: list[str]) -> None:
2121

2222

2323
def run_checks() -> None:
24-
"""Run ruff, isort, black and tests (coverage+pytest).
24+
"""Run ruff, black, and tests (coverage+pytest).
2525
26-
This is intended to be invoked via the project script entrypoint, e.g.:
27-
`uv run checks` or `python -m python_response_time.pre_flight run_checks`
28-
when installed.
26+
Intended usage:
27+
`uv run checks`
2928
"""
3029
py = sys.executable
31-
_run([py, "-m", "ruff", "check", ".", "--fix", "--exit-zero"])
32-
_run([py, "-m", "isort", "."])
30+
31+
_run([py, "-m", "ruff", "check", ".", "--fix"])
3332
_run([py, "-m", "black", "."])
34-
_run([py, "-m", "ruff", "check", ".", "--exit-zero"])
33+
_run([py, "-m", "ruff", "check", "."])
3534
_run([py, "-m", "coverage", "run", "-m", "pytest"])
3635

3736
logger.info("All checks completed.")

tests/test_basic.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,6 @@ def test_num_requests():
1818
assert 0 < n <= 1_000_000
1919

2020

21-
def test_concurrency():
22-
"""CONCURRENCY should be an int > 0 and <= 10_000."""
23-
c = app_settings.CONCURRENCY
24-
assert isinstance(c, int)
25-
assert 0 < c <= 10_000
26-
27-
28-
def test_timeout():
29-
"""TIMEOUT should be a float > 0 and <= 120."""
30-
t = app_settings.TIMEOUT
31-
assert isinstance(t, float)
32-
assert 0 < t <= 120
33-
34-
3521
def test_log_level():
3622
"""LOG_LEVEL should be a valid log level string."""
3723
level = app_settings.LOG_LEVEL

uv.lock

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)