Skip to content

SG-34910 Fixup SSLEOFError exception #346

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions shotgun_api3/shotgun.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import os
import re
import copy
import ssl
import stat # used for attachment upload
import sys
import time
Expand Down Expand Up @@ -111,14 +112,6 @@ def _is_mimetypes_broken():
have a self-signed internal certificate that isn't included in our certificate bundle, you may
not require the added security provided by enforcing this.
"""
try:
import ssl
except ImportError as e:
if "SHOTGUN_FORCE_CERTIFICATE_VALIDATION" in os.environ:
raise ImportError("%s. SHOTGUN_FORCE_CERTIFICATE_VALIDATION environment variable prevents "
"disabling SSL certificate validation." % e)
LOG.debug("ssl not found, disabling certificate validation")
NO_SSL_VALIDATION = True

# ----------------------------------------------------------------------------
# Version
Expand Down Expand Up @@ -3594,6 +3587,22 @@ def _make_call(self, verb, path, body, headers):
attempt += 1
try:
return self._http_request(verb, path, body, req_headers)
except ssl.SSLEOFError as e:
# SG-34910 - EOF occurred in violation of protocol (_ssl.c:2426)
# This issue seems to be related to proxy and keep alive.
# It looks like, sometimes, the proxy drops the connection on
# the TCP/TLS level despites the keep-alive. So we need to close
# the connection and make a new attempt.
LOG.debug("SSLEOFError: {}".format(e))
self._close_connection()
if attempt == max_rpc_attempts:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.config.max_rpc_attempts is hardcoding the value of 3. Maybe we can now get rid of this one and use self.MAX_ATTEMPTS instead. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded yes and no. It's the default value of the config object but users can select a different value.

Also, I see that this variable is still used at other places. So I kind of want to keep it as is for the moment.

LOG.debug("Request failed. Giving up after %d attempts." % attempt)
raise
# This is the exact same block as the "except Exception" bellow.
# We need to do it here because the next except will match it
# otherwise and will not re-attempt.
# When we drop support of Python 2 and we will probably drop the
# next except, we might want to remove this except too.
except ssl_error_classes as e:
# Test whether the exception is due to the fact that this is an older version of
# Python that cannot validate certificates encrypted with SHA-2. If it is, then
Expand Down Expand Up @@ -3625,17 +3634,19 @@ def _make_call(self, verb, path, body, headers):

self._close_connection()
if attempt == max_rpc_attempts:
LOG.debug("Request failed. Giving up after %d attempts." % attempt)
raise
except Exception:
self._close_connection()
if attempt == max_rpc_attempts:
LOG.debug("Request failed. Giving up after %d attempts." % attempt)
raise
LOG.debug(
"Request failed, attempt %d of %d. Retrying in %.2f seconds..." %
(attempt, max_rpc_attempts, rpc_attempt_interval)
)
time.sleep(rpc_attempt_interval)

LOG.debug(
"Request failed, attempt %d of %d. Retrying in %.2f seconds..." %
(attempt, max_rpc_attempts, rpc_attempt_interval)
)
time.sleep(rpc_attempt_interval)

def _http_request(self, verb, path, body, headers):
"""
Expand Down
96 changes: 96 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
import datetime
import sys
import os
from . import mock
from .mock import patch, MagicMock
import ssl
import time
import types
import uuid
Expand Down Expand Up @@ -1839,6 +1841,100 @@ def test_status_not_200(self, mock_request):
mock_request.return_value = (response, {})
self.assertRaises(shotgun_api3.ProtocolError, self.sg.find_one, 'Shot', [])

@patch('shotgun_api3.shotgun.Http.request')
def test_make_call_retry(self, mock_request):
response = MagicMock(name="response mock", spec=dict)
response.status = 200
response.reason = 'reason'
mock_request.return_value = (response, {})

bak_rpc_attempt_interval = self.sg.config.rpc_attempt_interval
self.sg.config.rpc_attempt_interval = 0
try:
# First: make the request raise a consistent exception
mock_request.side_effect = Exception("not working")
with self.assertLogs(
'shotgun_api3', level='DEBUG'
) as cm1, self.assertRaises(
Exception
) as cm2:
self.sg.info()

self.assertEqual(cm2.exception.args[0], "not working")
log_content = "\n".join(cm1.output)
for i in [1,2]:
self.assertIn(
f"Request failed, attempt {i} of 3. Retrying",
log_content,
)
self.assertIn(
"Request failed. Giving up after 3 attempts.",
log_content,
)

# Then, make the exception happening only once and prove the
# retry works
def my_side_effect(*args, **kwargs):
try:
if my_side_effect.counter<1:
raise Exception("not working")

return mock.DEFAULT
finally:
my_side_effect.counter += 1

my_side_effect.counter = 0
mock_request.side_effect = my_side_effect
with self.assertLogs('shotgun_api3', level='DEBUG') as cm:
self.assertIsInstance(
self.sg.info(),
dict,
)

log_content = "\n".join(cm.output)
self.assertIn(
"Request failed, attempt 1 of 3. Retrying",
log_content,
)
self.assertNotIn(
"Request failed, attempt 2 of 3. Retrying",
log_content,
)

# Last: raise a SSLEOFError exception - SG-34910
def my_side_effect2(*args, **kwargs):
try:
if my_side_effect2.counter<1:
raise ssl.SSLEOFError(
"EOF occurred in violation of protocol (_ssl.c:2426)"
)

return mock.DEFAULT
finally:
my_side_effect2.counter += 1

my_side_effect2.counter = 0
mock_request.side_effect = my_side_effect2

with self.assertLogs('shotgun_api3', level='DEBUG') as cm:
self.assertIsInstance(
self.sg.info(),
dict,
)

log_content = "\n".join(cm.output)
self.assertIn("SSLEOFError", log_content)
self.assertIn(
"Request failed, attempt 1 of 3. Retrying",
log_content,
)
self.assertNotIn(
"Request failed, attempt 2 of 3. Retrying",
log_content,
)
finally:
self.sg.config.rpc_attempt_interval = bak_rpc_attempt_interval

@patch('shotgun_api3.shotgun.Http.request')
def test_sha2_error(self, mock_request):
# Simulate the exception raised with SHA-2 errors
Expand Down
Loading