Skip to content
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

feat: improve error responses #719

Merged
merged 1 commit into from
Oct 30, 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
6 changes: 4 additions & 2 deletions ibmcloudant/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# coding: utf-8
# © Copyright IBM Corporation 2020, 2021.
# © Copyright IBM Corporation 2020, 2024.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -19,7 +19,7 @@
from ibm_cloud_sdk_core import IAMTokenManager, DetailedResponse, BaseService, ApiException, get_authenticator
from .couchdb_session_authenticator import CouchDbSessionAuthenticator
from .couchdb_session_get_authenticator_patch import new_construct_authenticator
from .cloudant_base_service import new_init, new_prepare_request, new_set_service_url, new_set_default_headers
from .cloudant_base_service import new_init, new_prepare_request, new_set_default_headers, new_set_http_client, new_set_service_url
from .couchdb_session_token_manager import CouchDbSessionTokenManager
from .cloudant_v1 import CloudantV1
from .features.changes_follower import ChangesFollower
Expand All @@ -34,3 +34,5 @@
CloudantV1.set_default_headers = new_set_default_headers

CloudantV1.prepare_request = new_prepare_request

CloudantV1.set_http_client = new_set_http_client
117 changes: 96 additions & 21 deletions ibmcloudant/cloudant_base_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# coding: utf-8

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

from ibm_cloud_sdk_core.authenticators import Authenticator
from requests import Response, Session
from requests.cookies import RequestsCookieJar

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


old_init = CloudantV1.__init__

_old_init = CloudantV1.__init__

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


old_set_service_url = CloudantV1.set_service_url

_old_set_service_url = CloudantV1.set_service_url

def new_set_service_url(self, service_url: str):
old_set_service_url(self, service_url)
_old_set_service_url(self, service_url)
try:
if isinstance(self.authenticator, CouchDbSessionAuthenticator):
self.authenticator.token_manager.set_service_url(service_url)
except AttributeError:
pass # in case no authenticator is configured yet, pass


old_set_default_headers = CloudantV1.set_default_headers

_old_set_default_headers = CloudantV1.set_default_headers

def new_set_default_headers(self, headers: Dict[str, str]):
old_set_default_headers(self, headers)
_old_set_default_headers(self, headers)
if isinstance(self.authenticator, CouchDbSessionAuthenticator):
combined_headers = {}
combined_headers.update(headers)
Expand All @@ -112,18 +112,82 @@ def new_set_default_headers(self, headers: Dict[str, str]):
)
self.authenticator.token_manager.set_default_headers(combined_headers)

_old_set_disable_ssl_verification = CloudantV1.set_disable_ssl_verification

old_set_disable_ssl_verification = CloudantV1.set_disable_ssl_verification


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


old_prepare_request = CloudantV1.prepare_request

def _error_response_hook(response:Response, *args, **kwargs) -> Optional[Response]:
# pylint: disable=W0613
# unused args and kwargs required by requests event hook interface
"""Function for augmenting error responses.
Converts the Cloudant response to better match the
standard error response formats including adding a
trace ID and appending the Cloudant/CouchDB error
reason to the message.

Follows the requests event hook pattern.

:param response: the requests Response object
:type response: Response

:return: A new response object, defaults to the existing response
:rtype: Response,optional
"""
# Only hook into error responses
# Ignore HEAD request responses because there is no body to read
if not response.ok and response.request.method != 'HEAD':
content_type = response.headers.get('content-type')
# If it isn't JSON don't mess with it!
if content_type is not None and content_type.startswith('application/json'):
try:
error_json: dict = response.json()
# Only augment if there isn't a trace or errors already
send_augmented_response = False
if 'trace' not in error_json:
if 'errors' not in error_json:
error = error_json.get('error')
reason = error_json.get('reason')
if error is not None:
error_model: dict = {'code': error, 'message': f'{error}'}
if reason:
error_model['message'] += f': {reason}'
error_json['errors'] = [error_model]
send_augmented_response = True
if 'errors' in error_json:
trace = response.headers.get('x-couch-request-id')
if trace is not None:
# Augment trace if there was a value
error_json['trace'] = trace
send_augmented_response = True
if send_augmented_response:
# It'd be nice to just change content on response, but it's internal.
# Instead copy the named attributes to a new Response and then set
# the encoding and bytes of the modified error body.
error_response = Response()
error_response.status_code = response.status_code
error_response.headers = response.headers
error_response.url = response.url
error_response.history = response.history
error_response.reason = response.reason
error_response.cookies = response.cookies
error_response.elapsed = response.elapsed
error_response.request = response.request
error_response.encoding = 'utf-8'
error_response.raw = BytesIO(dumps(error_json).encode('utf-8'))
return error_response
except JSONDecodeError:
# If we couldn't read the JSON we just return the response as-is
# so the exception can surface elsewhere.
pass
return response

_old_prepare_request = CloudantV1.prepare_request

def new_prepare_request(self,
method: str,
Expand Down Expand Up @@ -159,4 +223,15 @@ def new_prepare_request(self,
if segment_to_validate.startswith('_'):
raise ValueError('{0} {1} starts with the invalid _ character.'.format(rule.error_parameter_name,
unquote(segment_to_validate)))
return old_prepare_request(self, method, url, *args, headers=headers, params=params, data=data, files=files, **kwargs)
return _old_prepare_request(self, method, url, *args, headers=headers, params=params, data=data, files=files, **kwargs)

def add_hooks(self):
response_hooks = self.get_http_client().hooks['response']
if _error_response_hook not in response_hooks:
response_hooks.append(_error_response_hook)

_old_set_http_client = CloudantV1.set_http_client

def new_set_http_client(self, http_client: Session) -> None:
_old_set_http_client(self, http_client)
add_hooks(self)
Loading