Handle NotAcceptable when Accept-Encoding: identity is not allowed

Apparently, some HPE Gen 10 Plus machines do not allow identity encoding
when BIOS registries are requested. Add a fallback to Connector.

Change-Id: I7363df5f5f74705307990dda9dfc7baebd5c07a3
This commit is contained in:
Dmitry Tantsur 2024-03-25 17:18:20 +01:00
parent 5dd160cb35
commit be29045601
No known key found for this signature in database
GPG Key ID: 315B2AF9FD216C60
4 changed files with 111 additions and 43 deletions

View File

@ -0,0 +1,5 @@
---
fixes:
- |
Adds a work-around for cases where ``Accept-Encoding: identity`` is not
accepted.

View File

@ -51,15 +51,6 @@ class Connector(object):
# By default, we ask HTTP server to shut down HTTP connection we've
# just used.
self._session.headers['Connection'] = 'close'
# NOTE(TheJulia): Depending on the BMC, offering compression as an
# acceptable response changes the ETag behavior to offering an
# automatic "weak" ETag response, which is appropriate because the
# body content *may* not be a byte for byte match given compression.
# Overall, the value of compression is less than the value of concise
# interaction with the BMC. Setting to identity basically means
# "without modification or compression". By default, python-requests
# indicates responses can be compressed.
self._session.headers['Accept-Encoding'] = 'identity'
if username or password:
LOG.warning('Passing username and password to Connector is '
@ -115,7 +106,8 @@ class Connector(object):
PUT, PATCH, etc...
:param path: The sub-URI or absolute URL path to the resource.
:param data: Optional JSON data.
:param headers: Optional dictionary of headers.
:param headers: Optional dictionary of headers. Use None value
to remove a default header.
:param blocking: Whether to block for asynchronous operations.
:param timeout: Max time in seconds to wait for blocking async call or
for requests library to connect and read. If a custom
@ -138,12 +130,25 @@ class Connector(object):
url = path if urlparse.urlparse(path).netloc else urlparse.urljoin(
self._url, path)
headers = headers or {}
headers = (headers or {}).copy()
lc_headers = [k.lower() for k in headers]
if data is not None and 'content-type' not in lc_headers:
headers['Content-Type'] = 'application/json'
if 'odata-version' not in lc_headers:
headers['OData-Version'] = '4.0'
# NOTE(TheJulia): Depending on the BMC, offering compression as an
# acceptable response changes the ETag behavior to offering an
# automatic "weak" ETag response, which is appropriate because the
# body content *may* not be a byte for byte match given compression.
# Overall, the value of compression is less than the value of concise
# interaction with the BMC. Setting to identity basically means
# "without modification or compression". By default, python-requests
# indicates responses can be compressed.
if 'accept-encoding' not in lc_headers:
headers['Accept-Encoding'] = 'identity'
# Allow removing default headers
headers = {k: v for k, v in headers.items() if v is not None}
# TODO(lucasagomes): We should mask the data to remove sensitive
# information
LOG.debug('HTTP request: %(method)s %(url)s; headers: %(headers)s; '
@ -268,6 +273,21 @@ class Connector(object):
**extra_session_req_kwargs)
else:
raise
except exceptions.NotAcceptableError as e:
# NOTE(dtantsur): some HPE Gen 10 Plus machines do not allow
# identity encoding when fetching registries.
if (method.lower() == 'get'
and headers.get('Accept-Encoding') == 'identity'):
LOG.warning('Server has indicated a NotAcceptable for %s, '
'retrying without identity encoding', e)
headers = dict(headers, **{'Accept-Encoding': None})
return self._op(
method, path, data=data, headers=headers,
blocking=blocking, timeout=timeout,
server_side_retries_left=server_side_retries_left,
**extra_session_req_kwargs)
else:
raise
if blocking and response.status_code == 202:
if not response.headers.get('Location'):
m = ('HTTP response for %(method)s request to %(url)s '

View File

@ -160,6 +160,10 @@ class AccessError(HTTPError):
pass
class NotAcceptableError(HTTPError):
pass
class MissingXAuthToken(HTTPError):
message = ('No X-Auth-Token returned from remote host when '
'attempting to establish a session. Error: %(error)s')
@ -176,6 +180,8 @@ def raise_for_response(method, url, response):
elif response.status_code in (http_client.UNAUTHORIZED,
http_client.FORBIDDEN):
raise AccessError(method, url, response)
elif response.status_code == http_client.NOT_ACCEPTABLE:
raise NotAcceptableError(method, url, response)
elif response.status_code >= http_client.INTERNAL_SERVER_ERROR:
raise ServerSideError(method, url, response)
else:

View File

@ -168,18 +168,25 @@ class ConnectorOpTestCase(base.TestCase):
server_side_retries=10, server_side_retries_delay=3)
self.conn._auth = mock_auth
self.data = {'fake': 'data'}
self.headers = {'X-Fake': 'header'}
self.headers = {'Accept-Encoding': 'identity', 'OData-Version': '4.0'}
self.session = mock.Mock(spec=requests.Session)
self.conn._session = self.session
self.request = self.session.request
self.request.return_value.status_code = http_client.OK
def test_ok_get(self):
self.conn._op('GET', path='fake/path', headers=self.headers)
self.conn._op('GET', path='fake/path')
self.request.assert_called_once_with(
'GET', 'http://foo.bar:1234/fake/path',
headers=self.headers, json=None, verify=True, timeout=60)
def test_ok_get_with_headers(self):
self.conn._op('GET', path='fake/path', headers={'answer': '42'})
self.request.assert_called_once_with(
'GET', 'http://foo.bar:1234/fake/path',
headers=dict(self.headers, answer='42'),
json=None, verify=True, timeout=60)
def test_response_callback(self):
mock_response_callback = mock.MagicMock()
self.conn._response_callback = mock_response_callback
@ -188,34 +195,42 @@ class ConnectorOpTestCase(base.TestCase):
self.assertEqual(1, mock_response_callback.call_count)
def test_ok_get_url_redirect_false(self):
self.conn._op('GET', path='fake/path', headers=self.headers,
allow_redirects=False)
self.conn._op('GET', path='fake/path', allow_redirects=False)
self.request.assert_called_once_with(
'GET', 'http://foo.bar:1234/fake/path',
headers=self.headers, json=None, allow_redirects=False,
verify=True, timeout=60)
def test_ok_post(self):
self.conn._op('POST', path='fake/path', data=self.data.copy(),
headers=self.headers)
self.conn._op('POST', path='fake/path', data=self.data.copy())
self.request.assert_called_once_with(
'POST', 'http://foo.bar:1234/fake/path',
json=self.data, headers=self.headers, verify=True, timeout=60)
json=self.data,
headers=dict(self.headers, **{'Content-Type': 'application/json'}),
verify=True, timeout=60)
def test_ok_post_with_headers(self):
self.conn._op('POST', path='fake/path', data=self.data.copy(),
headers={'answer': 42})
self.request.assert_called_once_with(
'POST', 'http://foo.bar:1234/fake/path',
json=self.data,
headers=dict(self.headers, **{'Content-Type': 'application/json',
'answer': 42}),
verify=True, timeout=60)
def test_ok_put(self):
self.conn._op('PUT', path='fake/path', data=self.data.copy(),
headers=self.headers)
self.conn._op('PUT', path='fake/path', data=self.data.copy())
self.request.assert_called_once_with(
'PUT', 'http://foo.bar:1234/fake/path',
json=self.data, headers=self.headers, verify=True, timeout=60)
headers=dict(self.headers, **{'Content-Type': 'application/json'}),
json=self.data, verify=True, timeout=60)
def test_ok_delete(self):
expected_headers = self.headers.copy()
expected_headers['OData-Version'] = '4.0'
self.conn._op('DELETE', path='fake/path', headers=self.headers.copy())
self.conn._op('DELETE', path='fake/path')
self.request.assert_called_once_with(
'DELETE', 'http://foo.bar:1234/fake/path',
headers=expected_headers, json=None, verify=True, timeout=60)
headers=self.headers, json=None, verify=True, timeout=60)
def test_ok_post_with_session(self):
self.conn._session.headers = {}
@ -242,23 +257,24 @@ class ConnectorOpTestCase(base.TestCase):
'GET', 'http://foo.bar:1234' + path,
headers=expected_headers, json=None, verify=True, timeout=60)
def test_odata_version_header_redfish_no_headers(self):
path = '/redfish/v1/bar'
expected_headers = {'OData-Version': '4.0'}
self.conn._op('GET', path=path)
self.request.assert_called_once_with(
'GET', 'http://foo.bar:1234' + path,
headers=expected_headers, json=None, verify=True, timeout=60)
def test_odata_version_header_redfish_existing_header(self):
path = '/redfish/v1/foo'
headers = {'OData-Version': '3.0'}
expected_headers = dict(headers)
expected_headers = dict(self.headers, **headers)
self.conn._op('GET', path=path, headers=headers)
self.request.assert_called_once_with(
'GET', 'http://foo.bar:1234' + path,
headers=expected_headers, json=None, verify=True, timeout=60)
def test_remove_header_accept_encoding(self):
path = '/redfish/v1/foo'
headers = {'Accept-Encoding': None}
self.headers.pop('Accept-Encoding')
self.conn._op('GET', path=path, headers=headers)
self.request.assert_called_once_with(
'GET', 'http://foo.bar:1234' + path,
headers=self.headers, json=None, verify=True, timeout=60)
def test_timed_out_session_unable_to_create_session(self):
self.conn._auth.can_refresh_session.return_value = False
self.session.auth = None
@ -522,6 +538,29 @@ class ConnectorOpTestCase(base.TestCase):
self.assertEqual(0, mock_sleep.call_count)
self.assertEqual(1, self.request.call_count)
def test_op_retry_without_identity(self):
self.request.side_effect = [
mock.Mock(status_code=http_client.NOT_ACCEPTABLE),
mock.Mock(status_code=http_client.OK),
]
self.conn._op('GET', 'http://foo.bar')
self.assertEqual(2, self.request.call_count)
headers_no_accept = self.headers.copy()
headers_no_accept.pop('Accept-Encoding')
self.request.assert_has_calls([
mock.call('GET', 'http://foo.bar', headers=self.headers,
json=None, verify=True, timeout=60),
mock.call('GET', 'http://foo.bar', headers=headers_no_accept,
json=None, verify=True, timeout=60),
])
def test_op_retry_without_identity_fails(self):
self.request.return_value.status_code = http_client.NOT_ACCEPTABLE
self.assertRaises(exceptions.NotAcceptableError, self.conn._op,
'GET', 'http://foo.bar')
self.assertEqual(2, self.request.call_count)
def test_access_error(self):
self.conn._auth = None
@ -715,8 +754,7 @@ class ConnectorOpTestCase(base.TestCase):
'/redfish/v1/Systems/1',
data={'Boot': {'BootSourceOverrideTarget': 'Cd',
'BootSourceOverrideEnabled': 'Once'}},
headers={'X-Fake': 'header',
'If-Match': '"3d7b8a7360bf2941d"'},
headers=dict(self.headers, **{'If-Match': '"3d7b8a7360bf2941d"'}),
blocking=False,
timeout=60)
@ -738,8 +776,8 @@ class ConnectorOpTestCase(base.TestCase):
'/redfish/v1/Systems/1',
data={'Boot': {'BootSourceOverrideTarget': 'Cd',
'BootSourceOverrideEnabled': 'Once'}},
headers={'X-Fake': 'header',
'If-Match': 'W/"3d7b8a7360bf2941d"'},
headers=dict(self.headers,
**{'If-Match': 'W/"3d7b8a7360bf2941d"'}),
blocking=False,
timeout=60)
@ -759,7 +797,7 @@ class ConnectorOpTestCase(base.TestCase):
'/redfish/v1/Systems/1',
data={'Boot': {'BootSourceOverrideTarget': 'Cd',
'BootSourceOverrideEnabled': 'Once'}},
headers={'X-Fake': 'header'},
headers=self.headers,
blocking=False,
timeout=60)
@ -787,8 +825,7 @@ class ConnectorOpTestCase(base.TestCase):
'/redfish/v1/Systems/1',
data={'Boot': {'BootSourceOverrideTarget': 'Cd',
'BootSourceOverrideEnabled': 'Once'}},
headers={'X-Fake': 'header',
'If-Match': '"3d7b8a7360bf2941d"'},
headers=dict(self.headers, **{'If-Match': '"3d7b8a7360bf2941d"'}),
blocking=False,
timeout=60)
@ -818,7 +855,7 @@ class ConnectorOpTestCase(base.TestCase):
'/redfish/v1/Systems/1',
data={'Boot': {'BootSourceOverrideTarget': 'Cd',
'BootSourceOverrideEnabled': 'Once'}},
headers={'X-Fake': 'header'},
headers=self.headers,
blocking=False,
timeout=60)
@ -846,7 +883,7 @@ class ConnectorOpTestCase(base.TestCase):
'/redfish/v1/Systems/1',
data={'Boot': {'BootSourceOverrideTarget': 'Cd',
'BootSourceOverrideEnabled': 'Once'}},
headers={'X-Fake': 'header'},
headers=self.headers,
blocking=False,
timeout=60)