From ac2a60dc0d2932583e70406b6d456e6ae094c3dd Mon Sep 17 00:00:00 2001 From: Yan Xiao <yanxiao@nvidia.com> Date: Fri, 15 Dec 2023 09:19:35 -0500 Subject: [PATCH] Add transaction id to errors Add transaction id to errors to help troubleshoot, including error when downloading object with truncted/missing segment, stat'ing or listing non-existent container. Change response headers returned by client.py Connection APIs such as get_object() from lowercase dict to CaseInsensitiveDict, as some error paths rely on this to retrieve the transaction id, while there are existing code paths using response.headers which is CaseInsensitiveDict from requests lib. Change-Id: Ice9cc9fe68684563f18ee527996e5a4292230a96 --- swiftclient/client.py | 10 ++- swiftclient/multithreading.py | 4 + swiftclient/service.py | 25 ++++-- swiftclient/shell.py | 22 ++--- test/unit/test_service.py | 27 +++++- test/unit/test_shell.py | 165 +++++++++++++++++++++++++++++++++- test/unit/test_swiftclient.py | 8 ++ 7 files changed, 239 insertions(+), 22 deletions(-) diff --git a/swiftclient/client.py b/swiftclient/client.py index 06350901..074e3062 100644 --- a/swiftclient/client.py +++ b/swiftclient/client.py @@ -19,10 +19,13 @@ OpenStack Swift client library used internally import socket import re import logging +from urllib3.exceptions import HTTPError as urllib_http_error + import warnings from requests.exceptions import RequestException, SSLError import http.client as http_client +from requests.structures import CaseInsensitiveDict from urllib.parse import quote, unquote from urllib.parse import urljoin, urlparse, urlunparse from time import sleep, time @@ -287,7 +290,7 @@ class _RetryBody(_ObjectBody): try: buf = self.resp.read(length) self.bytes_read += len(buf) - except (socket.error, RequestException): + except (socket.error, urllib_http_error, RequestException): if self.conn.attempts > self.conn.retries: raise if (not buf and self.bytes_read < self.expected_length and @@ -735,9 +738,9 @@ def get_auth(auth_url, user, key, **kwargs): def resp_header_dict(resp): - resp_headers = {} + resp_headers = CaseInsensitiveDict() for header, value in resp.getheaders(): - header = parse_header_string(header).lower() + header = parse_header_string(header) resp_headers[header] = parse_header_string(value) return resp_headers @@ -1926,6 +1929,7 @@ class Connection: is_not_range_request and resp_chunk_size and self.attempts <= self.retries and rheaders.get('transfer-encoding') is None) + if retry_is_possible: body = _RetryBody(body.resp, self, container, obj, resp_chunk_size=resp_chunk_size, diff --git a/swiftclient/multithreading.py b/swiftclient/multithreading.py index eaccec68..9659997c 100644 --- a/swiftclient/multithreading.py +++ b/swiftclient/multithreading.py @@ -89,6 +89,10 @@ class OutputManager: msg = msg % fmt_args self.error_print_pool.submit(self._print_error, msg) + def error_with_txn_id(self, swift_err): + self.error("{}\nFailed Transaction ID: {}".format( + swift_err.value, swift_err.transaction_id or 'unknown')) + def get_error_count(self): return self.error_count diff --git a/swiftclient/service.py b/swiftclient/service.py index 3d1b4764..d0e93075 100644 --- a/swiftclient/service.py +++ b/swiftclient/service.py @@ -32,6 +32,9 @@ from time import time from threading import Thread from queue import Queue from queue import Empty as QueueEmpty +from requests.exceptions import RequestException +from socket import error as socket_error +from urllib3.exceptions import HTTPError as urllib_http_error from urllib.parse import quote import json @@ -68,12 +71,16 @@ class ResultsIterator: class SwiftError(Exception): def __init__(self, value, container=None, obj=None, - segment=None, exc=None): + segment=None, exc=None, transaction_id=None): self.value = value self.container = container self.obj = obj self.segment = segment self.exception = exc + if transaction_id is None: + self.transaction_id = getattr(exc, 'transaction_id', None) + else: + self.transaction_id = transaction_id def __str__(self): value = repr(self.value) @@ -459,7 +466,9 @@ class _SwiftReader: try: self._content_length = int(headers.get('content-length')) except ValueError: - raise SwiftError('content-length header must be an integer') + raise SwiftError( + 'content-length header must be an integer', + transaction_id=self._txn_id) def __iter__(self): for chunk in self._body: @@ -1306,9 +1315,15 @@ class SwiftService: else: pseudodir = True - for chunk in obj_body: - if fp is not None: - fp.write(chunk) + try: + for chunk in obj_body: + if fp is not None: + fp.write(chunk) + except (socket_error, + urllib_http_error, + RequestException) as err: + raise ClientException( + str(err), http_response_headers=headers) finish_time = time() diff --git a/swiftclient/shell.py b/swiftclient/shell.py index c5fb2190..4f6cf2de 100755 --- a/swiftclient/shell.py +++ b/swiftclient/shell.py @@ -227,7 +227,7 @@ def st_delete(parser, args, output_manager, return_parser=False): output_manager.error('Error Deleting: {0}: {1}' .format(p, r['error'])) except SwiftError as err: - output_manager.error(err.value) + output_manager.error_with_txn_id(err) st_download_options = '''[--all] [--marker <marker>] [--prefix <prefix>] @@ -484,11 +484,13 @@ def st_download(parser, args, output_manager, return_parser=False): "Object '%s/%s' not found", container, obj) continue output_manager.error( - "Error downloading object '%s/%s': %s", - container, obj, error) + "Error downloading object '%s/%s': %s\n" + "Failed Transaction ID: %s", + container, obj, error, + getattr(error, 'transaction_id', 'unknown')) except SwiftError as e: - output_manager.error(e.value) + output_manager.error_with_txn_id(e) except Exception as e: output_manager.error(e) @@ -670,7 +672,7 @@ def st_list(parser, args, output_manager, return_parser=False): prt_bytes(totals['bytes'], human)) except SwiftError as e: - output_manager.error(e.value) + output_manager.error_with_txn_id(e) st_stat_options = '''[--lh] [--header <header:value>] @@ -761,7 +763,7 @@ def st_stat(parser, args, output_manager, return_parser=False): st_stat_options, st_stat_help) except SwiftError as e: - output_manager.error(e.value) + output_manager.error_with_txn_id(e) st_post_options = '''[--read-acl <acl>] [--write-acl <acl>] [--sync-to <sync-to>] @@ -867,7 +869,7 @@ def st_post(parser, args, output_manager, return_parser=False): raise(result["error"]) except SwiftError as e: - output_manager.error(e.value) + output_manager.error_with_txn_id(e) st_copy_options = '''[--destination </container/object>] [--fresh-metadata] @@ -972,7 +974,7 @@ def st_copy(parser, args, output_manager, return_parser=False): return except SwiftError as e: - output_manager.error(e.value) + output_manager.error_with_txn_id(e) st_upload_options = '''[--changed] [--skip-identical] [--segment-size <size>] @@ -1270,7 +1272,7 @@ def st_upload(parser, args, output_manager, return_parser=False): "to chunk the object") except SwiftError as e: - output_manager.error(e.value) + output_manager.error_with_txn_id(e) st_capabilities_options = '''[--json] [<proxy_url>] @@ -1332,7 +1334,7 @@ def st_capabilities(parser, args, output_manager, return_parser=False): del capabilities['swift'] _print_compo_cap('Additional middleware', capabilities) except SwiftError as e: - output_manager.error(e.value) + output_manager.error_with_txn_id(e) st_info = st_capabilities diff --git a/test/unit/test_service.py b/test/unit/test_service.py index 99351255..1c6207d8 100644 --- a/test/unit/test_service.py +++ b/test/unit/test_service.py @@ -27,6 +27,7 @@ from unittest import mock from concurrent.futures import Future from hashlib import md5 from queue import Queue, Empty as QueueEmptyError +from requests.structures import CaseInsensitiveDict from time import sleep import swiftclient @@ -167,8 +168,10 @@ class TestSwiftReader(unittest.TestCase): self.assertIsNone(sr._actual_md5) # Check Contentlength raises error if it isn't an integer - self.assertRaises(SwiftError, self.sr, 'path', 'body', - {'content-length': 'notanint'}) + with self.assertRaises(SwiftError) as cm: + self.sr('path', 'body', {'content-length': 'notanint'}) + self.assertEqual("'content-length header must be an integer'", + str(cm.exception)) def test_iterator_usage(self): def _consume(sr): @@ -653,6 +656,7 @@ class TestSwiftError(unittest.TestCase): self.assertIsNone(se.exception) self.assertEqual(str(se), '5') + self.assertNotIn(str(se), 'Transaction ID') def test_swifterror_creation(self): test_exc = Exception('test exc') @@ -665,6 +669,25 @@ class TestSwiftError(unittest.TestCase): self.assertEqual(se.exception, test_exc) self.assertEqual(str(se), '5 container:con object:obj segment:seg') + self.assertNotIn(str(se), 'Transaction ID') + + def test_swifterror_clientexception_creation(self): + test_exc = ClientException( + Exception('test exc'), + http_response_headers=CaseInsensitiveDict({ + 'x-trans-id': 'someTransId'}) + ) + se = SwiftError(5, 'con', 'obj', 'seg', test_exc) + + self.assertEqual(se.value, 5) + self.assertEqual(se.container, 'con') + self.assertEqual(se.obj, 'obj') + self.assertEqual(se.segment, 'seg') + self.assertEqual(se.exception, test_exc) + + self.assertEqual('someTransId', se.transaction_id) + self.assertNotIn('someTransId', str(se)) + self.assertIn('5 container:con object:obj segment:seg', str(se)) class TestServiceUtils(unittest.TestCase): diff --git a/test/unit/test_shell.py b/test/unit/test_shell.py index eedbc4be..13940794 100644 --- a/test/unit/test_shell.py +++ b/test/unit/test_shell.py @@ -15,17 +15,21 @@ import io import contextlib +import socket from genericpath import getmtime import getpass import hashlib import json import logging import os +from requests.structures import CaseInsensitiveDict import tempfile import unittest from unittest import mock import textwrap from time import localtime, mktime, strftime, strptime +from requests.exceptions import RequestException +from urllib3.exceptions import HTTPError import swiftclient from swiftclient.service import SwiftError @@ -207,6 +211,28 @@ class TestShell(unittest.TestCase): ' Sync To: other\n' ' Sync Key: secret\n') + @mock.patch('swiftclient.service.Connection') + def test_stat_container_not_found(self, connection): + connection.return_value.head_container.side_effect = \ + swiftclient.ClientException( + 'test', + http_status=404, + http_response_headers=CaseInsensitiveDict({ + 'x-trans-id': 'someTransId'}) + ) + argv = ["", "stat", "container"] + + with CaptureOutput() as output: + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + connection.return_value.head_container.assert_called_with( + 'container', headers={}, resp_chunk_size=65536, + response_dict={}) + + self.assertIn('Container \'container\' not found\n' + 'Failed Transaction ID: someTransId', + output.err) + @mock.patch('swiftclient.service.Connection') def test_stat_container_with_headers(self, connection): return_headers = { @@ -285,6 +311,27 @@ class TestShell(unittest.TestCase): ' ETag: md5\n' ' Manifest: manifest\n') + @mock.patch('swiftclient.service.Connection') + def test_stat_object_not_found(self, connection): + connection.return_value.head_object.side_effect = \ + swiftclient.ClientException( + 'test', http_status=404, + http_response_headers=CaseInsensitiveDict({ + 'x-trans-id': 'someTransId'}) + ) + argv = ["", "stat", "container", "object"] + + with CaptureOutput() as output: + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + connection.return_value.head_object.assert_called_with( + 'container', 'object', headers={}, resp_chunk_size=65536, + response_dict={}) + + self.assertIn('test: 404\n' + 'Failed Transaction ID: someTransId', + output.err) + @mock.patch('swiftclient.service.Connection') def test_stat_object_with_headers(self, connection): return_headers = { @@ -707,6 +754,117 @@ class TestShell(unittest.TestCase): swiftclient.shell.main(argv) self.assertEqual('objcontent', output.out) + def _do_test_download_clientexception(self, exc): + retry_calls = [] + + def _fake_retry(conn, *args, **kwargs): + retry_calls.append((args, kwargs)) + conn.attempts += 1 + + body = mock.MagicMock() + body.resp.read.side_effect = RequestException('test_exc') + return (CaseInsensitiveDict({ + 'content-type': 'text/plain', + 'etag': '2cbbfe139a744d6abbe695e17f3c1991', + 'x-trans-id': 'someTransId'}), + body) + + argv = ["", "download", "container", "object", "--retries", "1"] + with CaptureOutput() as output: + with mock.patch(BUILTIN_OPEN) as mock_open: + with mock.patch("swiftclient.service.Connection._retry", + _fake_retry): + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + mock_open.assert_called_with('object', 'wb', 65536) + self.assertEqual([ + ((None, swiftclient.client.get_object, 'container', 'object'), + {'headers': {}, + 'query_string': None, + 'resp_chunk_size': 65536, + 'response_dict': {}}), + ((None, swiftclient.client.get_object, 'container', 'object'), + {'attempts': 1, + 'headers': {'If-Match': mock.ANY, 'Range': 'bytes=0-'}, + 'query_string': None, + 'resp_chunk_size': 65536, + 'response_dict': {}})], + retry_calls) + self.assertIn('Error downloading object \'container/object\': ' + 'test_exc', + str(output.err)) + self.assertIn('someTransId', str(output.err)) + + def test_download_request_exception(self): + self._do_test_download_clientexception(RequestException('text_exc')) + + def test_download_socket_error(self): + self._do_test_download_clientexception(socket.error()) + + def test_download_http_error(self): + self._do_test_download_clientexception(HTTPError) + + def test_download_request_exception_retries_0(self): + retry_calls = [] + + def _fake_retry(conn, *args, **kwargs): + retry_calls.append((args, kwargs)) + conn.attempts += 1 + + body = mock.MagicMock() + body.__iter__.side_effect = RequestException('test_exc') + return (CaseInsensitiveDict({ + 'content-type': 'text/plain', + 'etag': '2cbbfe139a744d6abbe695e17f3c1991', + 'x-trans-id': 'someTransId'}), + body) + + argv = ["", "download", "container", "object", "--retries", "0"] + with CaptureOutput() as output: + with mock.patch(BUILTIN_OPEN) as mock_open: + with mock.patch("swiftclient.service.Connection._retry", + _fake_retry): + with self.assertRaises(SystemExit): + swiftclient.shell.main(argv) + mock_open.assert_called_with('object', 'wb', 65536) + self.assertEqual([ + ((None, swiftclient.client.get_object, 'container', 'object'), + {'headers': {}, + 'query_string': None, + 'resp_chunk_size': 65536, + 'response_dict': {}}), ], + retry_calls) + self.assertIn('Error downloading object \'container/object\': ' + 'test_exc', + str(output.err)) + self.assertIn('someTransId', str(output.err)) + + @mock.patch('swiftclient.service.Connection') + def test_download_bad_content_length(self, connection): + objcontent = io.BytesIO(b'objcontent') + connection.return_value.get_object.side_effect = [ + (CaseInsensitiveDict({ + 'content-type': 'text/plain', + 'content-length': 'BAD', + 'etag': '2cbbfe139a744d6abbe695e17f3c1991', + 'x-trans-id': 'someTransId'}), + objcontent) + ] + with CaptureOutput() as output: + with self.assertRaises(SystemExit): + with mock.patch(BUILTIN_OPEN) as mock_open: + argv = ["", "download", "container", "object"] + swiftclient.shell.main(argv) + connection.return_value.get_object.assert_called_with( + 'container', 'object', headers={}, resp_chunk_size=65536, + response_dict={}) + mock_open.assert_called_with('object', 'wb', 65536) + + self.assertIn("Error downloading object \'container/object\': " + "'content-length header must be an integer'" + "\nFailed Transaction ID: someTransId", + str(output.err)) + @mock.patch('swiftclient.service.shuffle') @mock.patch('swiftclient.service.Connection') def test_download_shuffle(self, connection, mock_shuffle): @@ -1901,7 +2059,9 @@ class TestShell(unittest.TestCase): with self.assertRaises(SystemExit): swiftclient.shell.main(argv) - self.assertEqual(output.err, 'Account not found\n') + self.assertEqual( + output.err, + 'Account not found\nFailed Transaction ID: unknown\n') @mock.patch('swiftclient.service.Connection') def test_post_container(self, connection): @@ -2093,7 +2253,8 @@ class TestShell(unittest.TestCase): self.assertEqual( output.err, 'Combination of multiple objects and destination ' - 'including object is invalid\n') + 'including object is invalid\n' + 'Failed Transaction ID: unknown\n') @mock.patch('swiftclient.service.Connection') def test_copy_object_bad_auth(self, connection): diff --git a/test/unit/test_swiftclient.py b/test/unit/test_swiftclient.py index be4f4a5e..a34e94f1 100644 --- a/test/unit/test_swiftclient.py +++ b/test/unit/test_swiftclient.py @@ -1118,6 +1118,14 @@ class TestGetObject(MockHttpTest): self.assertEqual('%ff', headers.get('x-non-utf-8-header', '')) self.assertEqual('%FF', headers.get('x-binary-header', '')) + self.assertEqual('t\xe9st', headers.get('X-Utf-8-Header', '')) + self.assertEqual('%ff', headers.get('X-Non-Utf-8-Header', '')) + self.assertEqual('%FF', headers.get('X-Binary-Header', '')) + + self.assertEqual('t\xe9st', headers.get('X-UTF-8-HEADER', '')) + self.assertEqual('%ff', headers.get('X-NON-UTF-8-HEADER', '')) + self.assertEqual('%FF', headers.get('X-BINARY-HEADER', '')) + def test_chunk_size_read_method(self): conn = c.Connection('http://auth.url/', 'some_user', 'some_key') with mock.patch('swiftclient.client.get_auth_1_0') as mock_get_auth: