In this commit, I implemented a basic HTTP request handler that listens on port 7878 and prints out incoming HTTP requests. This is the first step towards building a functional web server.
When I ran the server with cargo run and accessed 127.0.0.1:7878 in my browser, the server received and printed out the HTTP request headers. Each request contained:
- The request line:
GET / HTTP/1.1- This indicates a GET request to the root path using HTTP version 1.1 - Various HTTP headers including:
- Host information
- User-Agent details (Firefox browser on macOS)
- Accept headers for content negotiation
- Language preferences
- Encoding capabilities
- Connection information
- Cookie data
- Security-related fetch metadata
The server implementation:
- Creates a TCP listener bound to
127.0.0.1:7878 - Accepts incoming connections in a loop
- For each connection, reads the HTTP request line-by-line until an empty line (which marks the end of headers)
- Collects these lines into a vector and prints them out
Currently, the server receives the requests correctly but doesn't send any response back to the client. This explains why the browser continues to make repeated requests - it's waiting for a response that never comes.
In this commit, I upgraded the server to return actual HTML content to the browser, creating a complete request-response cycle.
-
HTML Response Generation: The server now constructs a proper HTTP response with:
- A status line
HTTP/1.1 200 OK - Content-Length header with the appropriate length
- The HTML content read from a local file (hello.html)
- A status line
-
Complete HTTP Flow: Instead of just receiving and printing requests, the server now:
- Reads the incoming HTTP request
- Processes it (currently in a basic way)
- Generates an appropriate response
- Sends the response back to the client
The key addition is the response formatting and sending:
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();This code:
- Creates the HTTP status line
- Reads the HTML content from a file
- Calculates the content length
- Formats these components into a valid HTTP response with proper headers
- Writes the response back to the TCP stream
When accessing the server now, the browser successfully receives and renders the HTML content. This is a significant improvement from the previous version where the browser would just wait indefinitely for a response.
In this commit, I enhanced the server to validate requests and provide different responses based on the requested path.
-
Request Validation: The server now checks if the request is for the root path (
/):- If the request is
GET / HTTP/1.1, it returns the main HTML page - For any other request, it returns a 404 error page
- If the request is
-
Error Handling: Added proper error responses with:
- HTTP status code 404 for "not found" resources
- Custom error page (404.html) with a user-friendly message
-
Code Refactoring: Reduced code duplication by:
- Extracting common logic for both success and error cases
- Using a tuple to determine the status line and filename based on the request
- Following the DRY (Don't Repeat Yourself) principle
The key improvement is the conditional response generation:
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();This code:
- Determines the appropriate status line and filename based on the request
- Uses a more concise pattern with destructuring to assign values
- Removes duplicate code by handling file reading and response generation once
When accessing the server now:
- Navigating to
127.0.0.1:7878shows the main page (hello.html) - Accessing any other path like
127.0.0.1:7878/somethingshows the 404 error page - The browser correctly displays different content based on the request path
This implementation follows proper HTTP protocol by returning appropriate status codes and content based on the requested resources.
In this commit, I simulated a slow request to demonstrate the limitations of a single-threaded web server.
I added a new route, /sleep, which artificially delays the response by sleeping for 10 seconds before returning the same content as the home page:
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(10));
("HTTP/1.1 200 OK", "hello.html")
}This test revealed a critical limitation of single-threaded servers:
-
Blocking Behavior: When I opened two browser windows and accessed the
/sleeproute in one of them, the entire server was blocked for 10 seconds. -
Request Queuing: While the first request was processing (sleeping), the second request to the home page (
/) had to wait in the queue even though it could have been processed immediately. -
Poor Scalability: This test simulates what would happen under heavier load - even simple requests get delayed when the server is handling slower requests.
The root cause of this behavior is in the server's main loop:
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}This code processes each connection sequentially. The server only begins handling the next connection after the current one is completely finished. When handling the /sleep route, the thread is blocked for 10 seconds, during which it cannot accept or process any other requests.
In a production environment, this limitation would cause:
- Poor User Experience: Users would experience seemingly random delays.
- Reduced Throughput: The server would handle fewer requests per second than it's capable of.
- Vulnerability to Denial of Service: Slow requests (either malicious or legitimate) could effectively make the server unresponsive.
This experiment clearly demonstrates why production web servers use multi-threading or async I/O to handle multiple requests concurrently.
In this commit, I transformed the server from a single-threaded to a multi-threaded architecture using a ThreadPool implementation.
I implemented a ThreadPool struct that manages a pool of worker threads. This approach:
- Creates a fixed number of threads (4 in this implementation) at server startup
- Distributes incoming requests across these worker threads
- Enables parallel processing of multiple requests
- Reuses threads rather than creating/destroying them for each request
-
ThreadPool Structure: Manages workers and communication channels:
pub struct ThreadPool { workers: Vec<Worker>, sender: mpsc::Sender<Job>, }
-
Workers: Each maintains a thread that processes jobs:
struct Worker { id: usize, thread: Option<thread::JoinHandle<()>>, }
-
Job Type: Represents a task to be executed by a worker:
type Job = Box<dyn FnOnce() + Send + 'static>;
-
Message Passing: Uses channels to safely distribute jobs:
let (sender, receiver) = mpsc::channel(); let receiver = Arc::new(Mutex::new(receiver));
With this implementation, when accessing the /sleep endpoint in one browser and the regular homepage in another:
- Non-blocking Behavior: The slow request no longer blocks the entire server
- Parallel Processing: Multiple requests are handled simultaneously
- Improved Responsiveness: Fast requests complete quickly even while slow requests are processing
- Better Resource Utilization: All CPU cores can be utilized
The key change is in how connections are processed:
// Before: Sequential processing
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
// After: Parallel processing with thread pool
for stream in listener.incoming() {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}Instead of handling each connection directly in the main thread, we now submit the connection handling as a job to the thread pool, which dispatches it to an available worker thread.
This multi-threaded implementation significantly improves the server's performance, scalability, and responsiveness. It can now handle multiple concurrent requests efficiently, which is essential for any production-grade web server. The thread pool approach also provides better resource management compared to creating a new thread for each connection.
In this bonus commit, I implemented an alternative constructor for the ThreadPool using the builder pattern and compared it with the original implementation.
I added a build() method to the ThreadPool struct as an alternative to the existing new() constructor:
pub fn build(size: usize) -> Result<ThreadPool, &'static str> {
if size == 0 {
return Err("ThreadPool size cannot be zero");
}
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
Ok(ThreadPool {
workers,
sender: Some(sender),
})
}-
new(): Uses
assert!()to panic if size is zeroassert!(size > 0);
-
build(): Returns a
Resulttype to handle errors gracefullyif size == 0 { return Err("ThreadPool size cannot be zero"); }
-
new(): Simple usage but with panic risk
let pool = ThreadPool::new(4);
-
build(): Requires error handling but safer
let pool = ThreadPool::build(4).unwrap_or_else(|err| { eprintln!("Failed to build ThreadPool: {}", err); std::process::exit(1); });
- new(): Follows Rust's convention where
new()is an infallible constructor - build(): Follows the builder pattern, which is more appropriate when construction might fail
- Explicit Error Handling: Forces the caller to consider error cases
- No Unexpected Panics: Better in production code where panicking is undesirable
- Flexible Configuration: Could be extended to support additional options (e.g., thread names, priorities)
- More Idiomatic: Aligns with Rust's preference for explicit error handling
- Use new(): For simple cases where failure is considered a programming error
- Use build(): For user-provided inputs or when graceful error handling is needed
This exercise demonstrates the importance of API design in library code. While both approaches create the same ThreadPool, they offer different trade-offs in terms of safety, ergonomics, and error handling. By providing both options, we give users the flexibility to choose the approach that best suits their needs.


