From 5c889c4d9a71c63d785330a7e5b4682094856815 Mon Sep 17 00:00:00 2001 From: Jon Nylander Date: Fri, 20 Mar 2026 17:36:16 +0100 Subject: [PATCH] Add timeout parameter to SendGrid API clients Default to a 30 second HTTP timeout to prevent hung processes. The timeout is passed through to python_http_client which already supports it. Callers can override or disable (via None) as needed. Timeouts via send() raise SendGridTimeoutError for clean handling. --- README.md | 10 ++++++++++ sendgrid/base_interface.py | 25 +++++++++++++++++++++---- sendgrid/helpers/mail/exceptions.py | 5 +++++ sendgrid/sendgrid.py | 8 ++++++-- sendgrid/twilio_email.py | 8 ++++++-- test/unit/test_sendgrid.py | 25 ++++++++++++++++++++++++- test/unit/test_twilio_email.py | 10 ++++++++++ 7 files changed, 82 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b1b36686f..a813aaf62 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,16 @@ print(response.body) print(response.headers) ``` +## Timeout + +By default, HTTP requests will time out after 30 seconds. You can change this by passing a `timeout` parameter (in seconds) when creating the client: + +```python +sg = sendgrid.SendGridAPIClient(api_key=os.environ.get('SENDGRID_API_KEY'), timeout=60) +``` + +To disable the timeout, pass `None`. + ## General v3 Web API Usage (With [Fluent Interface](https://sendgrid.com/blog/using-python-to-implement-a-fluent-interface-to-any-rest-api/)) ```python diff --git a/sendgrid/base_interface.py b/sendgrid/base_interface.py index f94f09479..c4755b3f2 100644 --- a/sendgrid/base_interface.py +++ b/sendgrid/base_interface.py @@ -1,9 +1,14 @@ +import socket +import urllib.error + import python_http_client +from .helpers.mail.exceptions import SendGridTimeoutError + region_host_dict = {'eu':'https://api.eu.sendgrid.com','global':'https://api.sendgrid.com'} class BaseInterface(object): - def __init__(self, auth, host, impersonate_subuser): + def __init__(self, auth, host, impersonate_subuser, timeout=30): """ Construct the Twilio SendGrid v3 API object. Note that the underlying client is being set up during initialization, @@ -20,6 +25,9 @@ def __init__(self, auth, host, impersonate_subuser): :type impersonate_subuser: string :param host: base URL for API calls :type host: string + :param timeout: the timeout (in seconds) for HTTP requests. + Defaults to 30. Set to None to disable. + :type timeout: int """ from . import __version__ self.auth = auth @@ -27,11 +35,13 @@ def __init__(self, auth, host, impersonate_subuser): self.version = __version__ self.useragent = 'sendgrid/{};python'.format(self.version) self.host = host + self.timeout = timeout self.client = python_http_client.Client( host=self.host, request_headers=self._default_headers, - version=3) + version=3, + timeout=timeout) @property def _default_headers(self): @@ -60,7 +70,13 @@ def send(self, message): if not isinstance(message, dict): message = message.get() - return self.client.mail.send.post(request_body=message) + try: + return self.client.mail.send.post(request_body=message) + except urllib.error.URLError as e: + if isinstance(e.reason, socket.timeout): + raise SendGridTimeoutError( + 'The SendGrid API request timed out') from e + raise def set_sendgrid_data_residency(self, region): """ @@ -78,6 +94,7 @@ def set_sendgrid_data_residency(self, region): self.client = python_http_client.Client( host=self.host, request_headers=self._default_headers, - version=3) + version=3, + timeout=self.timeout) else: raise ValueError("region can only be \"eu\" or \"global\"") diff --git a/sendgrid/helpers/mail/exceptions.py b/sendgrid/helpers/mail/exceptions.py index cbc311345..23265f9e7 100644 --- a/sendgrid/helpers/mail/exceptions.py +++ b/sendgrid/helpers/mail/exceptions.py @@ -8,6 +8,11 @@ class SendGridException(Exception): pass +class SendGridTimeoutError(SendGridException): + """Exception raised when an HTTP request to the SendGrid API times out""" + pass + + class ApiKeyIncludedException(SendGridException): """Exception raised for when Twilio SendGrid API Key included in message text""" diff --git a/sendgrid/sendgrid.py b/sendgrid/sendgrid.py index 912d8336e..0f03bacaa 100644 --- a/sendgrid/sendgrid.py +++ b/sendgrid/sendgrid.py @@ -33,7 +33,8 @@ def __init__( self, api_key=None, host='https://api.sendgrid.com', - impersonate_subuser=None): + impersonate_subuser=None, + timeout=30): """ Construct the Twilio SendGrid v3 API object. Note that the underlying client is being set up during initialization, @@ -51,8 +52,11 @@ def __init__( :type impersonate_subuser: string :param host: base URL for API calls :type host: string + :param timeout: the timeout (in seconds) for HTTP requests. + Defaults to 30. Set to None to disable. + :type timeout: int """ self.api_key = api_key or os.environ.get('SENDGRID_API_KEY') auth = 'Bearer {}'.format(self.api_key) - super(SendGridAPIClient, self).__init__(auth, host, impersonate_subuser) + super(SendGridAPIClient, self).__init__(auth, host, impersonate_subuser, timeout=timeout) diff --git a/sendgrid/twilio_email.py b/sendgrid/twilio_email.py index 78bd01815..e29933eb3 100644 --- a/sendgrid/twilio_email.py +++ b/sendgrid/twilio_email.py @@ -35,7 +35,8 @@ def __init__( username=None, password=None, host='https://email.twilio.com', - impersonate_subuser=None): + impersonate_subuser=None, + timeout=30): """ Construct the Twilio Email v3 API object. Note that the underlying client is being set up during initialization, @@ -59,6 +60,9 @@ def __init__( :type impersonate_subuser: string :param host: base URL for API calls :type host: string + :param timeout: the timeout (in seconds) for HTTP requests. + Defaults to 30. Set to None to disable. + :type timeout: int """ self.username = username or \ os.environ.get('TWILIO_API_KEY') or \ @@ -70,4 +74,4 @@ def __init__( auth = 'Basic ' + b64encode('{}:{}'.format(self.username, self.password).encode()).decode() - super(TwilioEmailAPIClient, self).__init__(auth, host, impersonate_subuser) + super(TwilioEmailAPIClient, self).__init__(auth, host, impersonate_subuser, timeout=timeout) diff --git a/test/unit/test_sendgrid.py b/test/unit/test_sendgrid.py index 328d978ab..bf9b82ffc 100644 --- a/test/unit/test_sendgrid.py +++ b/test/unit/test_sendgrid.py @@ -1,5 +1,10 @@ +import socket import unittest +from unittest.mock import patch +import urllib.error + import sendgrid +from sendgrid.helpers.mail.exceptions import SendGridTimeoutError class UnitTests(unittest.TestCase): def test_host_with_no_region(self): @@ -24,4 +29,22 @@ def test_with_region_is_none(self): def test_with_region_is_invalid(self): sg = sendgrid.SendGridAPIClient(api_key='MY_API_KEY') with self.assertRaises(ValueError): - sg.set_sendgrid_data_residency("abc") \ No newline at end of file + sg.set_sendgrid_data_residency("abc") + + def test_timeout_default(self): + sg = sendgrid.SendGridAPIClient(api_key='MY_API_KEY') + self.assertEqual(sg.timeout, 30) + self.assertEqual(sg.client.timeout, 30) + + def test_timeout_set_via_constructor(self): + sg = sendgrid.SendGridAPIClient(api_key='MY_API_KEY', timeout=10) + self.assertEqual(sg.timeout, 10) + self.assertEqual(sg.client.timeout, 10) + + @patch('python_http_client.Client') + def test_send_timeout_raises_sendgrid_timeout_error(self, MockClient): + sg = sendgrid.SendGridAPIClient(api_key='MY_API_KEY') + sg.client.mail.send.post.side_effect = urllib.error.URLError( + reason=socket.timeout('timed out')) + with self.assertRaises(SendGridTimeoutError): + sg.send({'key': 'value'}) \ No newline at end of file diff --git a/test/unit/test_twilio_email.py b/test/unit/test_twilio_email.py index 92269acff..281ad3900 100644 --- a/test/unit/test_twilio_email.py +++ b/test/unit/test_twilio_email.py @@ -35,3 +35,13 @@ def test_init_args(self): self.assertEqual(mail_client.username, 'username') self.assertEqual(mail_client.password, 'password') self.assertEqual(mail_client.auth, 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=') + + def test_timeout_default(self): + mail_client = TwilioEmailAPIClient('username', 'password') + self.assertEqual(mail_client.timeout, 30) + self.assertEqual(mail_client.client.timeout, 30) + + def test_timeout_set_via_constructor(self): + mail_client = TwilioEmailAPIClient('username', 'password', timeout=10) + self.assertEqual(mail_client.timeout, 10) + self.assertEqual(mail_client.client.timeout, 10)