Implementing API Timeouts and Retries in Python
A Step-by-Step Tutorial with Practical Examples

Search for a command to run...
A Step-by-Step Tutorial with Practical Examples

No comments yet. Be the first to comment.
Developers often tell me that the requirements they receive are usually vague and unclear. Another common complaint is that the requirements change midway, causing them to get stuck. Additionally, sometimes the deadline is set before the requirements...

Imagine you've got a REST API for listing users, and each user's information shows up in 12 fields. GET /v1/users/?page=1&page_size=15 { "pagination_data": { "total_records": 10000, "pages": 667, "prev_page_no": null, ...

Decoupling the Data Layer for Enhanced Code Modularity

Essentials for Safeguarding and Maintaining Your Sprint Cycles

When developing a backend system, it is likely that you will need to integrate it with other internal backend systems or third-party APIs. Suppose that the Orders API needs to access information from the Users API, but encounters an issue due to the Users service being unavailable. How long should the Orders API wait before timing out? If timeouts are not implemented, the Orders API will wait forever. Implementing API timeouts is crucial as it prevents long-running or unresponsive requests from adversely affecting a system's performance and availability.
For demonstration, I have created a dockerized FastAPI app with two services, one for Orders and the other one for Users. The Orders service calls the Users service to get the user data for an order.
I'll use the requests library in Python for making API calls.
The Orders service has an in-memory database which is a list of dictionaries that stores all the orders. Each order has an id, a user ID and the product title.
orders_db = [
{'id': 1, 'user': 1, 'product': 'Apple IPhone'},
{'id': 2, 'user': 2, 'product': 'Macbook'},
{'id': 3, 'user': 1, 'product': 'Wireless Charger'},
{'id': 4, 'user': 2, 'product': 'Book 1'}
]
The Orders service has an API GET /orders/?user_id=<user_id> (implemented here get_orders(user_id: int) -> dict), which will return all the orders created by the specified user ID in the format below.
So If you make an API request GET /orders/?user_id=1, it will return all orders by the user ID 1.
{
"user" : {
"id": 1,
"name": "Aaron Apple",
"phone": "99992222"
},
"orders": [
{
"id": 1,
"user": 1,
"product": "Apple IPhone"
},
{
"id": 3,
"user": 1,
"product": "Wireless Charger"
}
]
}
The timeout implementation in the requests library is straightforward, just add the timeout parameter and the timeout value in seconds.
import requests
API_TIMEOUT_IN_SECS = 10
r = requests.get('<URL>', timeout=API_TIMEOUT_IN_SECS)
So for our example, the call_api function implements the requests.get() method. The call_api has a parameter api_timeout_in_secs which by default is set to 10 seconds.
The get_user_data function gets the user_id as an argument.
It builds the User API URL and sends this URL and api_timeout_in_secs as 3 seconds to the call_api function.
import requests
from fastapi import FastAPI, HTTPException
app = FastAPI()
orders_db = [
{'id': 1, 'user': 1, 'product': 'Apple IPhone'},
{'id': 2, 'user': 2, 'product': 'Macbook'},
{'id': 3, 'user': 1, 'product': 'Wireless Charger'},
{'id': 4, 'user': 2, 'product': 'Book 1'}
]
class UserNotFound(HTTPException):
status_code = 404
def __init__(self, detail: str) -> None:
super().__init__(self.status_code, detail=detail)
def call_api(url: str, api_timeout_in_secs: int = 10):
return requests.get(url=url, timeout=api_timeout_in_secs)
def get_user_data(user_id: int) -> dict:
try:
response = call_api(
url=f'http://user_api_1:8200/users/{user_id}?fromService=order',
api_timeout_in_secs=3
)
except requests.exceptions.ReadTimeout:
raise UserNotFound('User not found: User API timed out')
if response.status_code == 200:
return response.json()
if response.status_code == 404:
return {'id': user_id}
raise UserNotFound('User not found: User API failed')
@app.get('/orders/')
def get_orders(user_id: int) -> dict:
orders = list(filter(lambda item: item['user'] == user_id, orders_db))
if not orders:
raise HTTPException(
status_code=404, detail='No orders found for this user ID')
return {
'user': get_user_data(user_id=user_id),
'orders': orders,
}
The Orders service will now wait only for 3 seconds, after which the requests.exceptions.ReadTimeout exception will be raised. This exception can be handled by the Orders service gracefully by raising a custom exception UserNotFound('User not found: User API timed out').
API retries are important to implement because they can help improve the resilience and reliability of a system by handling transient errors that can occur during communication with other systems or services.
When a client sends a request to an API, there is a chance that the request may not be fulfilled due to various reasons, such as network latency, server load, or dependencies on external services. In some cases, these errors are transient, meaning they are temporary and can be resolved by retrying the request after a certain interval of time.
Create a class called RetryManager which creates an HTTPAdapter with the max_retries set as the Retry instance. Return a request session object mounted with the adapter. Calling .get() or .post() methods on this session object will automatically retry requests.
import requests
from requests.adapters import HTTPAdapter, Retry
class RetryManager:
def __init__(self, num_retries: int = 3) -> None:
self.num_retries = num_retries
def get_session(self):
session = requests.Session()
BACKOFF_FACTOR = 1 # if 1 then 1s, 2s, 4s, 8s, 16s
STATUS_CODES_TO_RETRY_ON = [404, 502, 503, 504]
METHODS_TO_RETRY_ON = ['GET', 'POST', 'OPTIONS']
retries = Retry(
total=self.num_retries,
backoff_factor=BACKOFF_FACTOR,
status_forcelist=STATUS_CODES_TO_RETRY_ON,
method_whitelist=frozenset(METHODS_TO_RETRY_ON)
)
adapter = HTTPAdapter(max_retries=retries)
session.mount('http://', adapter)
session.mount('https://', adapter)
return session
def call_api_with_retry(url: str, api_timeout_in_secs: int = 10):
retry_session = RetryManager(
num_retries=5
).get_session()
return retry_session.get(url=url, timeout=api_timeout_in_secs)
To test the retries, we will make the get_user API in the User service return a 502.
@app.get('/users/{user_id}')
def get_user(user_id: int):
raise HTTPException(status_code=502, detail="Service down")
Now, we update the get_user_data in the Order service function to call the call_api_with_retry function.
def get_user_data(user_id: int) -> dict:
try:
response = call_api_with_retry(
url=f'http://user_api_1:8200/users/{user_id}?fromService=order',
api_timeout_in_secs=10
)
except requests.exceptions.ReadTimeout:
raise UserNotFound('User not found: User API timed out')
except requests.exceptions.RetryError:
raise UserNotFound('User not found: User service may be down')
if response.status_code == 200:
return response.json()
if response.status_code == 404:
return {'id': user_id}
raise UserNotFound('User not found: User API failed')
Now when we hit the get_orders API GET /orders/?user_id=1, it will internally call the User service and since the User service is down, it should retry this 5 times. The first retry will be immediate, the second will be after 2 seconds, then 4 seconds, 8 seconds and finally 16 seconds.
Check the user service logs below (1 request + 5 retried requests).
api_1 | 01/05/2023 11:45:37 INFO - "GET /users/1?fromService=order HTTP/1.1" 502
api_1 | 01/05/2023 11:45:37 INFO - "GET /users/1?fromService=order HTTP/1.1" 502
api_1 | 01/05/2023 11:45:39 INFO - "GET /users/1?fromService=order HTTP/1.1" 502
api_1 | 01/05/2023 11:45:43 INFO - "GET /users/1?fromService=order HTTP/1.1" 502
api_1 | 01/05/2023 11:45:51 INFO - "GET /users/1?fromService=order HTTP/1.1" 502
api_1 | 01/05/2023 11:46:07 INFO - "GET /users/1?fromService=order HTTP/1.1" 502
To implement timeouts and retries effectively, it is essential to carefully determine the parameters, such as the timeout value in seconds, number of retries, backoff_factor, and other relevant factors, based on the specific requirements of your system.
Implementing API timeouts and retries can have a significant impact on a system, improving its reliability, performance, and user experience.
Link to the project: https://github.com/akshays94/python-api-retry
I hope you found this helpful. Thanks for reading!