Skip to content

Commit fd8b268

Browse files
committed
add retry on RemoteDisconnect error, add backoff timer, increase testing
1 parent da1f990 commit fd8b268

File tree

12 files changed

+158
-36
lines changed

12 files changed

+158
-36
lines changed

http_client/src/vonage_http_client/errors.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -122,23 +122,6 @@ def __init__(self, response: Response, content_type: str):
122122
super().__init__(response, content_type)
123123

124124

125-
class FileStreamingError(HttpRequestError):
126-
"""Exception indicating an error occurred while streaming a file in a Vonage SDK
127-
request.
128-
129-
Args:
130-
response (requests.Response): The HTTP response object.
131-
content_type (str): The response content type.
132-
133-
Attributes (inherited from HttpRequestError parent exception):
134-
response (requests.Response): The HTTP response object.
135-
message (str): The returned error message.
136-
"""
137-
138-
def __init__(self, response: Response, content_type: str):
139-
super().__init__(response, content_type)
140-
141-
142125
class ServerError(HttpRequestError):
143126
"""Exception indicating an error was returned by a Vonage server in response to a
144127
Vonage SDK request.
@@ -156,3 +139,8 @@ class ServerError(HttpRequestError):
156139

157140
def __init__(self, response: Response, content_type: str):
158141
super().__init__(response, content_type)
142+
143+
144+
class FileStreamingError(VonageError):
145+
"""Exception indicating an error occurred while streaming a file in a Vonage SDK
146+
request."""

http_client/src/vonage_http_client/http_client.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from pydantic import BaseModel, Field, ValidationError, validate_call
77
from requests import PreparedRequest, Response
88
from requests.adapters import HTTPAdapter
9+
from requests.exceptions import ConnectionError
910
from requests.sessions import Session
11+
from urllib3 import Retry
1012
from vonage_http_client.auth import Auth
1113
from vonage_http_client.errors import (
1214
AuthenticationError,
@@ -90,7 +92,9 @@ def __init__(
9092
self._adapter = HTTPAdapter(
9193
pool_connections=self._http_client_options.pool_connections,
9294
pool_maxsize=self._http_client_options.pool_maxsize,
93-
max_retries=self._http_client_options.max_retries,
95+
max_retries=Retry(
96+
total=self._http_client_options.max_retries, backoff_factor=0.1
97+
),
9498
)
9599
self._session.mount('https://', self._adapter)
96100

@@ -216,6 +220,30 @@ def make_request(
216220
sent_data_type: Literal['json', 'form', 'query_params'] = 'json',
217221
token: Optional[str] = None,
218222
):
223+
"""Make an HTTP request to the specified host. This method will automatically
224+
handle retries in the event of a connection error caused by a RemoteDisconnect
225+
exception.
226+
227+
It will retry the amount of times equal to the maximum number of connections
228+
allowed in a connection pool. I.e., assuming if all connections in a given pool
229+
are in use but the TCP connections to the Vonage host have failed, it will retry
230+
the amount of times equal to the maximum number of connections in the pool.
231+
232+
Args:
233+
request_type (str): The type of request to make (GET, POST, PATCH, PUT, DELETE).
234+
host (str): The host to make the request to.
235+
request_path (str, optional): The path to make the request to.
236+
params (dict, optional): The parameters to send with the request.
237+
auth_type (str, optional): The type of authentication to use with the request.
238+
sent_data_type (str, optional): The type of data being sent with the request.
239+
token (str, optional): The token to use for OAuth2 authentication.
240+
241+
Returns:
242+
dict: The response data from the request.
243+
244+
Raises:
245+
ConnectionError: If the request fails after the maximum number of retries.
246+
"""
219247
url = f'https://{host}{request_path}'
220248
logger.debug(
221249
f'{request_type} request to {url}, with data: {params}; headers: {self._headers}'
@@ -248,8 +276,23 @@ def make_request(
248276
elif sent_data_type == 'form':
249277
request_params['data'] = params
250278

251-
with self._session.request(**request_params) as response:
252-
return self._parse_response(response)
279+
max_retries = self._http_client_options.pool_maxsize or 10
280+
attempt = 0
281+
while attempt < max_retries:
282+
try:
283+
with self._session.request(**request_params) as response:
284+
return self._parse_response(response)
285+
except ConnectionError as e:
286+
logger.debug(f'Connection Error: {e}')
287+
if 'RemoteDisconnected' in str(e.args):
288+
attempt += 1
289+
if attempt >= max_retries:
290+
raise e
291+
logger.debug(
292+
f'ConnectionError caused by RemoteDisconnected exception. Retrying request, attempt {attempt + 1} of {max_retries}'
293+
)
294+
else:
295+
raise e
253296

254297
def download_file_stream(self, url: str, file_path: str) -> bytes:
255298
"""Download a file from a URL and save it to a local file. This method streams the
@@ -272,6 +315,8 @@ def download_file_stream(self, url: str, file_path: str) -> bytes:
272315
)
273316
try:
274317
with self._session.get(url, headers=headers, stream=True) as response:
318+
if response.status_code >= 400:
319+
self._parse_response(response)
275320
with open(file_path, 'wb') as f:
276321
for chunk in response.iter_content(chunk_size=4096):
277322
f.write(chunk)
@@ -296,8 +341,6 @@ def _parse_response(self, response: Response) -> Union[dict, None]:
296341
try:
297342
return response.json()
298343
except JSONDecodeError:
299-
if hasattr(response.headers, 'Content-Type'):
300-
return response.content
301344
return None
302345
if response.status_code >= 400:
303346
content_type = response.headers['Content-Type'].split(';', 1)[0]

http_client/tests/test_http_client.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
from http.client import RemoteDisconnected
12
from json import loads
23
from os.path import abspath, dirname, join
4+
from unittest.mock import patch
35

46
import responses
57
from pytest import raises
6-
from requests import PreparedRequest, Response
8+
from requests import PreparedRequest, Response, Session
9+
from requests.exceptions import ConnectionError
710
from responses import matchers
811
from vonage_http_client.auth import Auth
912
from vonage_http_client.errors import (
@@ -15,7 +18,7 @@
1518
RateLimitedError,
1619
ServerError,
1720
)
18-
from vonage_http_client.http_client import HttpClient
21+
from vonage_http_client.http_client import HttpClient, HttpClientOptions
1922

2023
from testutils import build_response, get_mock_jwt_auth
2124

@@ -265,10 +268,10 @@ def test_download_file_stream():
265268
client = HttpClient(get_mock_jwt_auth())
266269
client.download_file_stream(
267270
url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab',
268-
file_path='file.mp3',
271+
file_path='http_client/tests/data/file_stream.mp3',
269272
)
270273

271-
with open('file.mp3', 'rb') as file:
274+
with open('http_client/tests/data/file_stream.mp3', 'rb') as file:
272275
file_content = file.read()
273276
assert file_content.startswith(b'ID3')
274277

@@ -280,15 +283,45 @@ def test_download_file_stream_error():
280283
'GET',
281284
'https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab',
282285
status_code=400,
286+
mock_path='400.json',
283287
)
284288

285289
client = HttpClient(get_mock_jwt_auth())
286-
try:
290+
with raises(FileStreamingError) as e:
287291
client.download_file_stream(
288292
url='https://api.nexmo.com/v1/files/aaaaaaaa-bbbb-cccc-dddd-0123456789ab',
289293
file_path='file.mp3',
290294
)
291-
except FileStreamingError as err:
292-
assert '400 response from' in err.message
293-
assert err.response.status_code == 400
294-
assert err.response.json()['title'] == 'Bad Request'
295+
assert '400 response from' in e.exconly()
296+
297+
298+
@patch.object(Session, 'request')
299+
def test_retry_on_remote_disconnected_connection_error(mock_request):
300+
mock_request.side_effect = ConnectionError(
301+
RemoteDisconnected('Remote end closed connection without response')
302+
)
303+
client = HttpClient(
304+
Auth(application_id=application_id, private_key=private_key),
305+
http_client_options=HttpClientOptions(),
306+
)
307+
params = {
308+
'test': 'post request',
309+
'testing': 'http client',
310+
}
311+
with raises(ConnectionError) as e:
312+
client.post(host='example.com', request_path='/post_json', params=params)
313+
assert mock_request.call_count == 10
314+
assert 'Remote end closed connection without response' in str(e.value)
315+
316+
317+
@patch.object(Session, 'request')
318+
def test_dont_retry_on_generic_connection_error(mock_request):
319+
mock_request.side_effect = ConnectionError('Error in connection to remote server')
320+
client = HttpClient(
321+
Auth(application_id=application_id, private_key=private_key),
322+
http_client_options=HttpClientOptions(),
323+
)
324+
with raises(ConnectionError) as e:
325+
client.get(host='example.com', request_path='/get_json')
326+
assert mock_request.call_count == 1
327+
assert 'Error in connection to remote server' in str(e.value)

jwt/CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# 1.1.5
2+
- Improve `verify_signature` docstring
3+
14
# 1.1.4
25
- Fix a bug with generating non-default JWTs
36

jwt/src/vonage_jwt/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.1.4'
1+
__version__ = '1.1.5'

jwt/src/vonage_jwt/verify_jwt.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,18 @@
44

55

66
def verify_signature(token: str, signature_secret: str = None) -> bool:
7-
"""Method to verify that an incoming JWT was sent by Vonage."""
7+
"""Method to verify that an incoming JWT was sent by Vonage.
8+
9+
Args:
10+
token (str): The token to verify.
11+
signature_secret (str, optional): The signature to verify the token against.
12+
13+
Returns:
14+
bool: True if the token is verified, False otherwise.
15+
16+
Raises:
17+
VonageVerifyJwtError: The signature could not be verified.
18+
"""
819

920
try:
1021
decode(token, signature_secret, algorithms='HS256')

pants.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[GLOBAL]
2-
pants_version = '2.23.0'
2+
pants_version = '2.24.0a0'
33

44
backend_packages = [
55
'pants.backend.python',

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
pytest>=8.0.0
22
requests>=2.31.0
33
responses>=0.24.1
4-
pydantic>=2.7.1
4+
pydantic>=2.9.2
55
typing-extensions>=4.9.0
66
pyjwt[crypto]>=1.6.4
77
toml>=0.10.2
8+
urllib3
89

910
-e jwt
1011
-e http_client

sample-6s.mp3

Lines changed: 0 additions & 1 deletion
This file was deleted.

voice/src/vonage_voice/voice.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pydantic import validate_call
44
from vonage_http_client.http_client import HttpClient
5+
from vonage_jwt.verify_jwt import verify_signature
56
from vonage_utils.types import Dtmf
67
from vonage_voice.models.ncco import NccoAction
78

@@ -274,3 +275,20 @@ def download_recording(self, url: str, file_path: str) -> bytes:
274275
bytes: The recording data.
275276
"""
276277
self._http_client.download_file_stream(url=url, file_path=file_path)
278+
279+
@validate_call
280+
def verify_signature(self, token: str, signature: str) -> bool:
281+
"""Verifies that a token has been signed with the provided signature. Used to
282+
verify that a webhook was sent by Vonage.
283+
284+
Args:
285+
token (str): The token to verify.
286+
signature (str): The signature to verify the token against.
287+
288+
Returns:
289+
bool: True if the token was signed with the provided signature, False otherwise.
290+
291+
Raises:
292+
VonageVerifyJwtError: The signature could not be verified.
293+
"""
294+
return verify_signature(token, signature)

0 commit comments

Comments
 (0)