Hello! Welcome to the second module of our course, "Distributed Communication Patterns."
Introduction
In the previous module, we built a solid theoretical foundation, exploring the fundamental challenges and failure models of distributed systems. We concluded by defining node failures, network partitions, and Byzantine failures. You learned that a simple server crash (a node failure) can make a service unavailable to its clients.
Today, we transition from theory to practice. We will write code that directly confronts these failures. Our focus will be on the most fundamental communication pattern: a synchronous request-response call. While simple, this pattern is the backbone of many systems, and making it resilient is a critical first step in building robust distributed applications.
Learning Outcome:
Implement a synchronous request-response pattern in Python for a simplified trading scenario, demonstrating robust handling of network timeouts and service unavailability.
We will simulate a scenario from your domain: a TradingService that needs to fetch the latest price from a MarketDataService before executing a trade. We will start with a "happy path" implementation and then systematically add safeguards to handle the inevitable failures of a distributed environment.
1. The Scenario: A Simplified Trading System
Let's imagine two microservices:
MarketDataService: A service that provides real-time prices for financial instruments.TradingService: A service that executes trades. Before placing a trade, it must get the latest price from theMarketDataService.
The communication is synchronous: the TradingService sends a request to the MarketDataService and blocks, waiting for a price before it can proceed.
Here is a basic, but fragile, implementation. We'll use Flask to create a mock MarketDataService and the requests library in the TradingService to make the call.
market_data_service.py
from flask import Flask, jsonify
import random
import time
app = Flask(__name__)
@app.route('/price/<ticker>')
def get_price(ticker):
# Simulate network latency or processing time
# time.sleep(2)
# Simulate a server error
# if random.random() < 0.5:
# return "Internal Server Error", 503
price = round(random.uniform(100, 500), 2)
return jsonify({"ticker": ticker, "price": price})
if __name__ == '__main__':
app.run(port=5001)
trading_service.py
import requests
def fetch_price(ticker):
"""Fetches the price for a given ticker from the MarketDataService."""
try:
url = f"http://127.0.0.1:5001/price/{ticker}"
response = requests.get(url)
response.raise_for_status() # Raises an exception for 4xx/5xx errors
price_data = response.json()
return price_data['price']
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
if __name__ == '__main__':
price = fetch_price("AAPL")
if price:
print(f"Executing trade for AAPL at price: {price}")
else:
print("Could not fetch price. Aborting trade.")
This code works perfectly on the "happy path." But what happens if the MarketDataService...
- ...is slow to respond?
- ...has crashed and is unavailable?
- ...is overloaded and returns a
503 Service Unavailableerror?
In its current form, our TradingService would either hang indefinitely or crash. Let's fix that.
2. Handling Timeouts
The first line of defense against an unresponsive service is a timeout. Without one, your service could wait forever for a response, consuming resources and potentially causing a cascade of failures.
The requests library makes setting timeouts straightforward. Let's learn the fundamentals.
Guide to Handling Python Requests Timeout
This article, 'Guide to Handling Python Requests Timeout', provides an excellent overview of why timeouts are necessary and how to implement them.
Please read the sections 'Understanding timeouts' and 'Setting timeouts in Python requests'. Focus on the basic syntax for the timeout parameter.
A simple timeout is good, but we can be more precise. A request involves two main phases:
- Connecting to the server.
- Reading the response from the server.
A failure can occur in either phase. A ConnectTimeout might happen if the MarketDataService is down. A ReadTimeout might happen if the service is up but is taking too long to process our request and send the data back. The requests library allows us to set separate timeouts for these two phases.
Guide to Handling Python Requests Timeout
Let's continue with the same article to explore how to handle specific timeout exceptions and configure separate connect and read timeouts.
Please read the sections 'Handling timeout exceptions' and 'Advanced timeout configurations' (specifically the subsection 'Setting separate connect and read timeouts'). Pay close attention to the different exception types (Timeout, ConnectTimeout, ReadTimeout) and the tuple syntax for setting separate timeouts.
Let's apply this to our TradingService. We'll set a 1-second connect timeout and a 3-second read timeout.
trading_service.py (Improved with Timeouts)
import requests
def fetch_price_with_timeout(ticker):
"""Fetches the price, handling timeouts."""
try:
url = f"http://127.0.0.1:5001/price/{ticker}"
# Set connect timeout to 1s, read timeout to 3s
response = requests.get(url, timeout=(1, 3))
response.raise_for_status()
price_data = response.json()
return price_data['price']
except requests.exceptions.ConnectTimeout:
print(f"Error: Connection to MarketDataService timed out.")
return None
except requests.exceptions.ReadTimeout:
print(f"Error: MarketDataService took too long to respond.")
return None
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
# To test this:
# 1. Test ConnectTimeout: Stop the market_data_service.py script and run this.
# 2. Test ReadTimeout: In market_data_service.py, uncomment `time.sleep(5)` and run both scripts.
Now, our TradingService no longer hangs. It fails fast and provides a clear error message, which is a much more desirable behavior in a distributed system.
3. Handling Service Errors and Unavailability
Timeouts handle unresponsive services, but what if the service responds quickly with an error? In HTTP, server-side errors are indicated by 5xx status codes (e.g., 500 Internal Server Error, 503 Service Unavailable).
Our initial code already included response.raise_for_status(). This is a convenient helper that automatically turns these error codes into a Python exception. Let's see exactly how to catch it.
How To Handle Errors & Exceptions with Requests and Python
This video from John Watson Rooney demonstrates how to use raise_for_status() and handle the resulting exceptions cleanly.
Please watch the segment 'Handling HTTP Status Code Errors (404)' from 00:50 to 02:59. The example uses a 404 error, but the principle is identical for the 5xx errors we are concerned with. Focus on how raise_for_status() triggers an HTTPError that can be caught in a try-except block.
What about when the service is completely down? This won't result in an HTTP error or a timeout (if the failure is immediate); it will cause a connection error. We need to handle that too.
How To Handle Errors & Exceptions with Requests and Python
Let's continue with the same video to see how to handle connection errors and how to use a general exception to simplify our code.
Watch the segment 'Handling Connection Errors' from 03:06 to 04:47. Pay attention to the requests.exceptions.ConnectionError and the introduction of the broader requests.exceptions.RequestException as a way to catch all request-related problems.
4. Implementation: A Robust Request-Response Client
We now have all the pieces to build a robust client function. It should handle:
- Connection timeouts.
- Read timeouts.
- HTTP server errors (
5xx). - Inability to connect to the service.
We can catch each specific exception, or we can use the general requests.exceptions.RequestException to catch any issue related to the requests library, which simplifies the code. For many use cases, catching the general exception is sufficient.
Here is the final, robust version of our fetch_price function.
trading_service.py (Final Robust Version)
import requests
def fetch_price_robust(ticker):
"""
Fetches the price for a given ticker with robust error handling.
- Sets connect and read timeouts.
- Handles timeouts, connection errors, and HTTP server errors.
"""
try:
url = f"http://127.0.0.1:5001/price/{ticker}"
response = requests.get(url, timeout=(1, 3))
# This will raise an HTTPError for 4xx or 5xx status codes.
response.raise_for_status()
price_data = response.json()
return price_data['price']
# Catch the general exception for all requests-related issues.
# This includes ConnectTimeout, ReadTimeout, HTTPError, ConnectionError etc.
except requests.exceptions.RequestException as e:
print(f"Could not fetch price for {ticker}. Reason: {e}")
return None
if __name__ == '__main__':
print("--- Testing Happy Path ---")
price = fetch_price_robust("AAPL")
if price:
print(f"Successfully fetched price for AAPL: {price}\n")
# To test failures, you need to run this script while manipulating
# the market_data_service.py script in another terminal.
# Test Scenario 1: Service Unavailable
# - Stop the market_data_service.py script.
# - Run this script. You should see a ConnectionError.
# Test Scenario 2: Read Timeout
# - In market_data_service.py, uncomment the `time.sleep(5)` line.
# - Run both scripts. You should see a ReadTimeout.
# Test Scenario 3: Server Error
# - In market_data_service.py, uncomment the line that returns a 503 error.
# - Run both scripts. You should see an HTTPError.
Your Task
To solidify your understanding, please set up and run the code provided.
- Save the two files:
market_data_service.pyandtrading_service.py. - Install dependencies:
pip install Flask requests. - Run the happy path:
- Open a terminal and run
python market_data_service.py. - Open a second terminal and run
python trading_service.py. - Confirm that the price is fetched successfully.
- Open a terminal and run
- Test the failure scenarios:
- Service Unavailable: Stop the market data service and run the trading service again. Observe the
ConnectionError. - Read Timeout: Uncomment the
time.sleep(5)line in the market data service, restart it, and run the trading service. Observe theReadTimeout. - Server Error: Comment out the sleep line and uncomment the line that returns a
503error in the market data service. Restart it and run the trading service. Observe theHTTPError.
- Service Unavailable: Stop the market data service and run the trading service again. Observe the
This hands-on exercise will give you a practical feel for how these fundamental resilience patterns work.
Conclusion
In this lesson, we took our first practical step into building resilient distributed systems. We implemented a synchronous request-response pattern and made it robust to common failures like timeouts and service unavailability.
Key Takeaways:
- Synchronous calls are simple but must be protected against failures in the downstream service.
- Timeouts are non-negotiable. Failing to set them can cause your service to hang, leading to cascading failures.
- Using separate connect and read timeouts gives you more granular control over fault tolerance.
- Use
try...exceptblocks to gracefully handle exceptions likerequests.exceptions.Timeout,requests.exceptions.ConnectionError, andrequests.exceptions.HTTPError. - The
response.raise_for_status()method is a convenient way to treat4xxand5xxHTTP responses as exceptions. - Catching the parent
requests.exceptions.RequestExceptioncan simplify error handling for clients that don't need to differentiate between failure types.
Preview of the Next Lesson:
Simply failing on a transient error might not be the best user experience or system behavior. If a service is temporarily unavailable, perhaps we should try again automatically. In our next lesson, we will build upon today's robust client by implementing client-side retry logic with exponential backoff and jitter, a powerful pattern for handling transient failures gracefully.
Can't find a good explanation? Sign up and we'll make it for you
Sign up