Skip to content

Commit 52782ad

Browse files
committed
feat: improve error responses
Create _error_response_hook function to augment error messages. Add hook to the client requests session. Unit tests for the hook.
1 parent 3689894 commit 52782ad

File tree

3 files changed

+570
-23
lines changed

3 files changed

+570
-23
lines changed

ibmcloudant/__init__.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# coding: utf-8
2-
# © Copyright IBM Corporation 2020, 2021.
2+
# © Copyright IBM Corporation 2020, 2024.
33
#
44
# Licensed under the Apache License, Version 2.0 (the "License");
55
# you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
1919
from ibm_cloud_sdk_core import IAMTokenManager, DetailedResponse, BaseService, ApiException, get_authenticator
2020
from .couchdb_session_authenticator import CouchDbSessionAuthenticator
2121
from .couchdb_session_get_authenticator_patch import new_construct_authenticator
22-
from .cloudant_base_service import new_init, new_prepare_request, new_set_service_url, new_set_default_headers
22+
from .cloudant_base_service import new_init, new_prepare_request, new_set_default_headers, new_set_http_client, new_set_service_url
2323
from .couchdb_session_token_manager import CouchDbSessionTokenManager
2424
from .cloudant_v1 import CloudantV1
2525
from .features.changes_follower import ChangesFollower
@@ -34,3 +34,5 @@
3434
CloudantV1.set_default_headers = new_set_default_headers
3535

3636
CloudantV1.prepare_request = new_prepare_request
37+
38+
CloudantV1.set_http_client = new_set_http_client

ibmcloudant/cloudant_base_service.py

+96-21
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# coding: utf-8
22

3-
# © Copyright IBM Corporation 2020, 2022.
3+
# © Copyright IBM Corporation 2020, 2024.
44
#
55
# Licensed under the Apache License, Version 2.0 (the "License");
66
# you may not use this file except in compliance with the License.
@@ -15,12 +15,17 @@
1515
# limitations under the License.
1616
"""
1717
Module to patch sdk core base service for session authentication
18+
and other helpful features.
1819
"""
1920
from collections import namedtuple
2021
from typing import Dict, Optional, Union, Tuple, List
2122
from urllib.parse import urlsplit, unquote
23+
from json import dumps
24+
from json.decoder import JSONDecodeError
25+
from io import BytesIO
2226

2327
from ibm_cloud_sdk_core.authenticators import Authenticator
28+
from requests import Response, Session
2429
from requests.cookies import RequestsCookieJar
2530

2631
from .common import get_sdk_headers
@@ -67,12 +72,10 @@ def __hash__(self):
6772
# Since Py3.6 dict is ordered so use a key only dict for our set
6873
rules_by_operation.setdefault(operation_id, dict()).setdefault(rule)
6974

70-
71-
old_init = CloudantV1.__init__
72-
75+
_old_init = CloudantV1.__init__
7376

7477
def new_init(self, authenticator: Authenticator = None):
75-
old_init(self, authenticator)
78+
_old_init(self, authenticator)
7679
# Overwrite default read timeout to 2.5 minutes
7780
if not ('timeout' in self.http_config):
7881
new_http_config = self.http_config.copy()
@@ -83,25 +86,22 @@ def new_init(self, authenticator: Authenticator = None):
8386
# Replacing BaseService's http.cookiejar.CookieJar as RequestsCookieJar supports update(CookieJar)
8487
self.jar = RequestsCookieJar(self.jar)
8588
self.authenticator.set_jar(self.jar) # Authenticators don't have access to cookie jars by default
89+
add_hooks(self)
8690

87-
88-
old_set_service_url = CloudantV1.set_service_url
89-
91+
_old_set_service_url = CloudantV1.set_service_url
9092

9193
def new_set_service_url(self, service_url: str):
92-
old_set_service_url(self, service_url)
94+
_old_set_service_url(self, service_url)
9395
try:
9496
if isinstance(self.authenticator, CouchDbSessionAuthenticator):
9597
self.authenticator.token_manager.set_service_url(service_url)
9698
except AttributeError:
9799
pass # in case no authenticator is configured yet, pass
98100

99-
100-
old_set_default_headers = CloudantV1.set_default_headers
101-
101+
_old_set_default_headers = CloudantV1.set_default_headers
102102

103103
def new_set_default_headers(self, headers: Dict[str, str]):
104-
old_set_default_headers(self, headers)
104+
_old_set_default_headers(self, headers)
105105
if isinstance(self.authenticator, CouchDbSessionAuthenticator):
106106
combined_headers = {}
107107
combined_headers.update(headers)
@@ -112,18 +112,82 @@ def new_set_default_headers(self, headers: Dict[str, str]):
112112
)
113113
self.authenticator.token_manager.set_default_headers(combined_headers)
114114

115+
_old_set_disable_ssl_verification = CloudantV1.set_disable_ssl_verification
115116

116-
old_set_disable_ssl_verification = CloudantV1.set_disable_ssl_verification
117-
118-
117+
# Note this is currently unused, but probably should be enabled.
118+
# To enable it we need to resolve whether CouchDbSessionAuthenticator
119+
# should ever be allowed to have a different value from the service client.
119120
def new_set_disable_ssl_verification(self, status: bool = False) -> None:
120-
old_set_disable_ssl_verification(self, status)
121+
_old_set_disable_ssl_verification(self, status)
121122
if isinstance(self.authenticator, CouchDbSessionAuthenticator):
122123
self.authenticator.token_manager.set_disable_ssl_verification(status)
123124

124-
125-
old_prepare_request = CloudantV1.prepare_request
126-
125+
def _error_response_hook(response:Response, *args, **kwargs) -> Optional[Response]:
126+
# pylint: disable=W0613
127+
# unused args and kwargs required by requests event hook interface
128+
"""Function for augmenting error responses.
129+
Converts the Cloudant response to better match the
130+
standard error response formats including adding a
131+
trace ID and appending the Cloudant/CouchDB error
132+
reason to the message.
133+
134+
Follows the requests event hook pattern.
135+
136+
:param response: the requests Response object
137+
:type response: Response
138+
139+
:return: A new response object, defaults to the existing response
140+
:rtype: Response,optional
141+
"""
142+
# Only hook into error responses
143+
# Ignore HEAD request responses because there is no body to read
144+
if not response.ok and response.request.method != 'HEAD':
145+
content_type = response.headers.get('content-type')
146+
# If it isn't JSON don't mess with it!
147+
if content_type is not None and content_type.startswith('application/json'):
148+
try:
149+
error_json: dict = response.json()
150+
# Only augment if there isn't a trace or errors already
151+
send_augmented_response = False
152+
if 'trace' not in error_json:
153+
if 'errors' not in error_json:
154+
error = error_json.get('error')
155+
reason = error_json.get('reason')
156+
if error is not None:
157+
error_model: dict = {'code': error, 'message': f'{error}'}
158+
if reason:
159+
error_model['message'] += f': {reason}'
160+
error_json['errors'] = [error_model]
161+
send_augmented_response = True
162+
if 'errors' in error_json:
163+
trace = response.headers.get('x-couch-request-id')
164+
if trace is not None:
165+
# Augment trace if there was a value
166+
error_json['trace'] = trace
167+
send_augmented_response = True
168+
if send_augmented_response:
169+
# It'd be nice to just change content on response, but it's internal.
170+
# Instead copy the named attributes to a new Response and then set
171+
# the encoding and bytes of the modified error body.
172+
error_response = Response()
173+
error_response.status_code = response.status_code
174+
error_response.headers = response.headers
175+
error_response.url = response.url
176+
error_response.history = response.history
177+
error_response.reason = response.reason
178+
error_response.cookies = response.cookies
179+
error_response.elapsed = response.elapsed
180+
error_response.request = response.request
181+
error_response.encoding = 'utf-8'
182+
error_response.raw = BytesIO(dumps(error_json).encode('utf-8'))
183+
return error_response
184+
except JSONDecodeError:
185+
# If we couldn't read the JSON we just return the response as-is
186+
# so the exception can surface elsewhere.
187+
pass
188+
return response
189+
190+
_old_prepare_request = CloudantV1.prepare_request
127191

128192
def new_prepare_request(self,
129193
method: str,
@@ -159,4 +223,15 @@ def new_prepare_request(self,
159223
if segment_to_validate.startswith('_'):
160224
raise ValueError('{0} {1} starts with the invalid _ character.'.format(rule.error_parameter_name,
161225
unquote(segment_to_validate)))
162-
return old_prepare_request(self, method, url, *args, headers=headers, params=params, data=data, files=files, **kwargs)
226+
return _old_prepare_request(self, method, url, *args, headers=headers, params=params, data=data, files=files, **kwargs)
227+
228+
def add_hooks(self):
229+
response_hooks = self.get_http_client().hooks['response']
230+
if _error_response_hook not in response_hooks:
231+
response_hooks.append(_error_response_hook)
232+
233+
_old_set_http_client = CloudantV1.set_http_client
234+
235+
def new_set_http_client(self, http_client: Session) -> None:
236+
_old_set_http_client(self, http_client)
237+
add_hooks(self)

0 commit comments

Comments
 (0)