-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
230 lines (186 loc) · 9.84 KB
/
main.py
File metadata and controls
230 lines (186 loc) · 9.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""
main.py - Entry Point
Wires all components together:
1. Configures the logging system (observability)
2. Creates a MetricsCollector to track request statistics
3. Registers route handlers on a Router
4. Creates a ThreadPool
5. Creates a TCPServer that feeds accepted sockets into the pool
6. Starts everything and handles Ctrl-C for a clean shutdown
This file intentionally contains minimal logic. Business logic lives in
handlers, protocol logic in request/response, and concurrency in thread_pool.
"""
import datetime
import json
import logging
import os
import signal
import socket
import sys
import time
from metrics import MetricsCollector
from request import HTTPRequest, parse_request
from response import HTTPResponse, make_error_response, make_json_response, make_text_response
from router import Router, make_static_handler
from server import TCPServer
from thread_pool import ThreadPool
# ── Logging configuration ──────────────────────────────────────────────────
# We configure once here at the application entry point. All other modules
# obtain a module-level logger via logging.getLogger(__name__) without any
# handler configuration — the root logger handles all output.
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
stream=sys.stdout,
)
logger = logging.getLogger(__name__)
# ── Server constants ───────────────────────────────────────────────────────
HOST: str = "0.0.0.0"
PORT: int = 8080
NUM_WORKERS: int = 10
# Seconds to wait for a client to send its request before giving up.
# Prevents a slow or stalled client from occupying a worker thread forever
# (a basic defence against slow-loris style resource exhaustion).
CLIENT_TIMEOUT: float = 5.0
# Resolve www/ relative to this file so the server works regardless of the
# current working directory when main.py is invoked.
WWW_DIR: str = os.path.join(os.path.dirname(__file__), "www")
# ── Shared state ───────────────────────────────────────────────────────────
# MetricsCollector is created at module level so every route handler and
# handle_connection can reference it without passing it as an argument.
metrics: MetricsCollector = MetricsCollector()
# ── Router ─────────────────────────────────────────────────────────────────
router = Router()
# ── Application route handlers ─────────────────────────────────────────────
@router.route("/")
def handle_home(request: HTTPRequest) -> HTTPResponse:
"""Plain-text home page — simplest possible response."""
return make_text_response(
"Welcome to the HTTP/1.1 server built from scratch!\n\n"
"Available routes:\n"
" GET / \u2192 this page\n"
" GET /api/hello \u2192 JSON greeting\n"
" GET /about \u2192 HTML static page\n\n"
"Observability:\n"
" GET /health \u2192 liveness check (status + uptime)\n"
" GET /metrics \u2192 request counters + thread pool stats\n"
)
@router.route("/api/hello")
def handle_api_hello(request: HTTPRequest) -> HTTPResponse:
"""JSON endpoint — demonstrates serialising Python data to a response."""
payload = {
"message": "Hello from the scratch-built HTTP server!",
"method": request.method,
"path": request.path,
"server": "python-raw/1.0",
}
return make_json_response(json.dumps(payload, indent=2).encode("utf-8"))
# Register the static /about route using the factory from router.py.
# The handler reads www/index.html from disk on each request so edits to
# the file are reflected without restarting the server (useful during dev).
router.add_route("/about", make_static_handler(os.path.join(WWW_DIR, "index.html")))
# ── Connection handler ─────────────────────────────────────────────────────
# How many bytes we attempt to read in one recv() call. 4 KiB covers the
# vast majority of real-world HTTP requests (headers + small bodies).
RECV_BUFFER: int = 4096
def handle_connection(client_socket: socket.socket) -> None:
"""
Read one HTTP request from *client_socket*, route it, and send the response.
This function is the unit of work submitted to the thread pool. It owns
the socket from the moment it is called and is responsible for closing it.
Design choice — single request per connection:
We intentionally do NOT support Keep-Alive. Supporting it would require
tracking Content-Length / Transfer-Encoding to know when one request
ends and the next begins, adding significant complexity for minimal
gain at this scale.
"""
# Apply a read timeout so a client that connects but never sends data
# does not hold a worker thread hostage indefinitely.
client_socket.settimeout(CLIENT_TIMEOUT)
try:
raw_data = client_socket.recv(RECV_BUFFER)
request = parse_request(raw_data)
if request.error is not None:
response = make_error_response(request.error)
else:
response = router.dispatch(request)
# Record the completed request in metrics (internal paths are
# filtered inside record_request so health probes don't skew counts).
metrics.record_request(request.path, response.status_code)
client_socket.sendall(response.to_bytes())
except socket.timeout:
logger.warning("Client timed out before sending a complete request")
except OSError as exc:
logger.error("Socket error while handling connection: %s", exc)
finally:
# Always close — leaking sockets will exhaust file descriptors.
client_socket.close()
# ── Main ───────────────────────────────────────────────────────────────────
def main() -> None:
"""Initialise and start the server, shut down cleanly on Ctrl-C."""
pool = ThreadPool(num_workers=NUM_WORKERS)
pool.start()
# ── Observability routes ───────────────────────────────────────────────
# These are registered inside main() because they close over `pool`,
# which is only available after the thread pool is constructed.
@router.route("/health")
def handle_health(request: HTTPRequest) -> HTTPResponse:
"""
Liveness probe — intended for load balancers and container orchestrators.
Returns 200 as long as the server is running and its event loop is
responsive. A non-200 from this endpoint signals that the instance
should be replaced.
"""
payload = {
"status": "ok",
"uptime_seconds": metrics.uptime_seconds,
# ISO-8601 UTC timestamp so log aggregators can correlate events
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
return make_json_response(json.dumps(payload, indent=2).encode("utf-8"))
@router.route("/metrics")
def handle_metrics(request: HTTPRequest) -> HTTPResponse:
"""
Readiness + instrumentation endpoint.
Returns request totals broken down by status code and path, plus a
live snapshot of the thread pool internals (active workers, queue
backlog). A growing queue_depth indicates the pool is undersized.
"""
data = metrics.snapshot()
idle = NUM_WORKERS - pool.active_workers
data["thread_pool"] = {
"total_workers": NUM_WORKERS,
"active_workers": pool.active_workers,
"idle_workers": idle,
# queue_depth > 0 for a sustained period means requests are
# waiting for a free thread — a signal to increase NUM_WORKERS.
"queue_depth": pool.queue_depth,
}
return make_json_response(json.dumps(data, indent=2).encode("utf-8"))
# ── TCPServer setup ────────────────────────────────────────────────────
# The server's connection_handler submits a task to the pool rather than
# calling handle_connection directly, so accept() returns immediately and
# the next connection can be accepted without waiting for I/O or parsing.
def enqueue_connection(client_socket: socket.socket) -> None:
pool.submit(lambda: handle_connection(client_socket))
server = TCPServer(
host=HOST,
port=PORT,
connection_handler=enqueue_connection,
)
# Handle SIGINT (Ctrl-C) and SIGTERM (docker stop / systemd) gracefully.
def _shutdown(signum: int, frame: object) -> None:
logger.info("Signal %d received \u2014 initiating graceful shutdown", signum)
server.stop()
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
logger.info("Starting HTTP server on http://%s:%d", HOST, PORT)
logger.info("Observability: http://localhost:%d/health | http://localhost:%d/metrics", PORT, PORT)
try:
server.start() # blocks until server.stop() is called
finally:
pool.shutdown(wait=True)
logger.info("Server exited cleanly")
if __name__ == "__main__":
main()