wscall: notify and record errors on failure (#44050)

This commit is contained in:
Lauréline Guérin 2020-09-07 14:31:32 +02:00
parent 426cf599f2
commit c1b508141f
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
9 changed files with 158 additions and 30 deletions

View File

@ -2368,7 +2368,7 @@ def test_sms_with_passerelle(pub):
with mock.patch('wcs.wscalls.get_secret_and_orig') as mocked_secret_and_orig:
mocked_secret_and_orig.return_value = ('secret', 'localhost')
with mock.patch('wcs.qommon.misc._http_request') as mocked_http_post:
mocked_http_post.return_value = ('response', '200', 'data', 'headers')
mocked_http_post.return_value = (mock.Mock(headers={}), 200, 'data', 'headers')
item.perform(formdata)
url = mocked_http_post.call_args[0][0]
payload = mocked_http_post.call_args[1]['body']

View File

@ -1,11 +1,14 @@
import json
import pytest
from wcs import fields
from wcs.formdef import FormDef
from wcs.logged_errors import LoggedError
from wcs.qommon.http_request import HTTPRequest
from wcs.qommon.template import Template
from wcs.wscalls import NamedWsCall
from utilities import create_temporary_pub, clean_temporary_pub
from utilities import get_app, create_temporary_pub, clean_temporary_pub
@pytest.fixture
@ -165,3 +168,56 @@ def test_webservice_delete(http_requests, pub):
pass
assert http_requests.get_last('url') == wscall.request['url']
assert http_requests.get_last('method') == 'DELETE'
@pytest.mark.parametrize('notify_on_errors', [True, False])
@pytest.mark.parametrize('record_on_errors', [True, False])
def test_webservice_on_error(http_requests, pub, emails, notify_on_errors, record_on_errors):
pub.cfg['debug'] = {'error_email': 'errors@localhost.invalid'}
pub.write_cfg()
NamedWsCall.wipe()
LoggedError.wipe()
FormDef.wipe()
wscall = NamedWsCall()
wscall.name = 'Hello world'
wscall.notify_on_errors = notify_on_errors
wscall.record_on_errors = record_on_errors
wscall.store()
assert wscall.slug == 'hello_world'
formdef = FormDef()
formdef.name = 'foobar'
formdef.fields = [
fields.CommentField(id='0', label='Foo Bar {{ webservice.hello_world }}', type='comment'),
]
formdef.store()
for url_part in ['json', 'json-err0', 'json-errheader0']:
wscall.request = {'url': 'http://remote.example.net/%s' % url_part}
wscall.store()
resp = get_app(pub).get('/foobar/')
assert 'Foo Bar ' in resp.text
assert emails.count() == 0
assert LoggedError.count() == 0
for url_part in ['400', '400-json', '404', '404-json', '500', 'json-err1', 'json-errheader1']:
status_code = 200
if not url_part.startswith('json'):
status_code = url_part[:3]
wscall.request = {'url': 'http://remote.example.net/%s' % url_part}
wscall.store()
resp = get_app(pub).get('/foobar/')
assert 'Foo Bar ' in resp.text
if notify_on_errors:
assert emails.count() == 1
assert "[ERROR] ['WSCALL'] Exception: %s whatever" % status_code in emails.emails
emails.empty()
else:
assert emails.count() == 0
if record_on_errors:
assert LoggedError.count() == 1
LoggedError.wipe()
else:
assert LoggedError.count() == 0

View File

@ -44,6 +44,8 @@ def wscall():
NamedWsCall.wipe()
wscall = NamedWsCall(name='xxx')
wscall.description = 'description'
wscall.notify_on_errors = True
wscall.record_on_errors = True
wscall.request = {
'url': 'http://remote.example.net/json',
'request_signature_key': 'xxx',
@ -55,7 +57,8 @@ def wscall():
return wscall
def test_wscalls_new(pub):
@pytest.mark.parametrize('value', [True, False])
def test_wscalls_new(pub, value):
create_superuser(pub)
NamedWsCall.wipe()
app = login(get_app(pub))
@ -69,8 +72,12 @@ def test_wscalls_new(pub):
# go to the page and add a webservice call
resp = app.get('/backoffice/settings/wscalls/')
resp = resp.click('New webservice call')
assert resp.form['notify_on_errors'].value == 'yes'
assert resp.form['record_on_errors'].value == 'yes'
resp.form['name'] = 'a new webservice call'
resp.form['description'] = 'description'
resp.form['notify_on_errors'] = value
resp.form['record_on_errors'] = value
resp.form['request$url'] = 'http://remote.example.net/json'
resp = resp.form.submit('submit')
assert resp.location == 'http://example.net/backoffice/settings/wscalls/'
@ -82,6 +89,8 @@ def test_wscalls_new(pub):
assert 'Edit webservice call' in resp.text
assert NamedWsCall.get('a_new_webservice_call').name == 'a new webservice call'
assert NamedWsCall.get('a_new_webservice_call').notify_on_errors == value
assert NamedWsCall.get('a_new_webservice_call').record_on_errors == value
def test_wscalls_view(pub, wscall):
@ -100,17 +109,25 @@ def test_wscalls_edit(pub, wscall):
resp = app.get('/backoffice/settings/wscalls/xxx/')
resp = resp.click(href='edit')
assert resp.form['name'].value == 'xxx'
assert resp.form['notify_on_errors'].value == 'yes'
assert resp.form['record_on_errors'].value == 'yes'
assert 'slug' in resp.form.fields
resp.form['description'] = 'bla bla bla'
resp.form['notify_on_errors'] = False
resp.form['record_on_errors'] = False
resp = resp.form.submit('submit')
assert resp.location == 'http://example.net/backoffice/settings/wscalls/xxx/'
resp = resp.follow()
assert NamedWsCall.get('xxx').description == 'bla bla bla'
assert NamedWsCall.get('xxx').notify_on_errors is False
assert NamedWsCall.get('xxx').record_on_errors is False
resp = app.get('/backoffice/settings/wscalls/xxx/')
resp = resp.click(href='edit')
assert resp.form['name'].value == 'xxx'
assert resp.form['notify_on_errors'].value is None
assert resp.form['record_on_errors'].value is None
assert 'slug' in resp.form.fields
resp.form['slug'] = 'yyy'
resp = resp.form.submit('submit')

View File

@ -324,6 +324,7 @@ class HttpRequestsMocking(object):
status, data, headers = {
'http://remote.example.net/204': (204, None, None),
'http://remote.example.net/400': (400, 'bad request', None),
'http://remote.example.net/400-json': (400, '{"err": 1, "err_desc": ":("}', None),
'http://remote.example.net/404': (404, 'page not found', None),
'http://remote.example.net/404-json': (404, '{"err": 1}', None),

View File

@ -52,6 +52,16 @@ class NamedWsCallUI(object):
value=self.wscall.request,
title=_('Request'), required=True)
form.add(
CheckboxWidget,
'notify_on_errors',
title=_('Notify on errors'),
value=self.wscall.notify_on_errors if self.wscall.slug else True)
form.add(
CheckboxWidget,
'record_on_errors',
title=_('Record on errors'),
value=self.wscall.record_on_errors if self.wscall.slug else True)
if not self.wscall.is_readonly():
form.add_submit('submit', _('Submit'))
form.add_submit('cancel', _('Cancel'))
@ -76,6 +86,8 @@ class NamedWsCallUI(object):
self.wscall.name = name
self.wscall.description = form.get_widget('description').parse()
self.wscall.notify_on_errors = form.get_widget('notify_on_errors').parse()
self.wscall.record_on_errors = form.get_widget('record_on_errors').parse()
self.wscall.request = form.get_widget('request').parse()
if self.wscall.slug:
self.wscall.slug = slug

View File

@ -317,10 +317,10 @@ class WcsPublisher(StubWcsPublisher):
conn.commit()
cur.close()
def notify_of_exception(self, exc_tuple, context=None):
def notify_of_exception(self, exc_tuple, context=None, record=True, notify=True):
exc_type, exc_value, tb = exc_tuple
error_summary = traceback.format_exception_only(exc_type, exc_value)
error_summary = error_summary[0][0:-1] # de-listify and strip newline
error_summary = error_summary[0][0:-1] # de-listify and strip newline
if context:
error_summary = '%s %s' % (context, error_summary)
@ -330,15 +330,17 @@ class WcsPublisher(StubWcsPublisher):
exc_type, exc_value,
tb))
self.log_internal_error(error_summary, plain_error_msg, record=True)
self.log_internal_error(error_summary, plain_error_msg, record=record, notify=notify)
def log_internal_error(self, error_summary, plain_error_msg, record=False):
def log_internal_error(self, error_summary, plain_error_msg, record=False, notify=True):
tech_id = None
if record:
logged_exception = LoggedError.record_exception(
error_summary, plain_error_msg, publisher=self)
if logged_exception:
tech_id = logged_exception.tech_id
if not notify:
return
try:
self.logger.log_internal_error(error_summary, plain_error_msg, tech_id)
except socket.error:

View File

@ -33,4 +33,11 @@
<li>{% trans "Method:" %} {% if wscall.request.method == 'POST' %}POST{% else %}GET{% endif %}</li>
</ul>
</div>
<div class="bo-block">
<ul>
<li>{% trans "Notify on errors:" %} {{ wscall.notify_on_errors|yesno }}</li>
<li>{% trans "Record on errors:" %} {{ wscall.record_on_errors|yesno }}</li>
</ul>
</div>
{% endblock %}

View File

@ -33,7 +33,7 @@ from ..qommon.form import *
from ..qommon.misc import json_loads
from wcs.workflows import (WorkflowStatusItem, register_item_class,
AbortActionException, AttachmentEvolutionPart)
from wcs.wscalls import call_webservice
from wcs.wscalls import call_webservice, get_app_error_code
class JournalWsCallErrorPart: #pylint: disable=C1001
@ -306,22 +306,8 @@ class WebserviceCallStatusItem(WorkflowStatusItem):
exc_info=sys.exc_info())
return
app_error_code = 0
app_error_code = get_app_error_code(response, data, self.response_type)
app_error_code_header = response.headers.get('x-error-code')
if app_error_code_header:
# result is good only if header value is '0'
try:
app_error_code = int(app_error_code_header)
except ValueError as e:
app_error_code = app_error_code_header
elif self.response_type == 'json':
try:
d = json_loads(data)
except (ValueError, TypeError) as e:
pass
else:
if isinstance(d, dict) and d.get('err'):
app_error_code = d['err']
if self.varname:
workflow_data.update({

View File

@ -28,8 +28,9 @@ from quixote import get_publisher, get_request
from .qommon import _, force_str
from .qommon.misc import simplify, get_variadic_url, JSONEncoder, json_loads
from .qommon.xml_storage import XmlStorableObject
from .qommon.form import (CompositeWidget, StringWidget, WidgetDict,
ComputedExpressionWidget, RadiobuttonsWidget, CheckboxWidget)
from .qommon.form import (
CompositeWidget, StringWidget, WidgetDict,
ComputedExpressionWidget, RadiobuttonsWidget, CheckboxWidget)
from .qommon import misc
from .qommon.template import Template
@ -37,9 +38,30 @@ from wcs.api_utils import sign_url, get_secret_and_orig, MissingSecret
from wcs.workflows import WorkflowStatusItem
def call_webservice(url, qs_data=None, request_signature_key=None,
def get_app_error_code(response, data, response_type):
app_error_code = 0
app_error_code_header = response.headers.get('x-error-code')
if app_error_code_header:
# result is good only if header value is '0'
try:
app_error_code = int(app_error_code_header)
except ValueError:
app_error_code = app_error_code_header
elif response_type == 'json':
try:
d = json_loads(data)
except (ValueError, TypeError):
pass
else:
if isinstance(d, dict) and d.get('err'):
app_error_code = d['err']
return app_error_code
def call_webservice(
url, qs_data=None, request_signature_key=None,
method=None, post_data=None, post_formdata=None, formdata=None,
cache=False, **kwargs):
cache=False, notify_on_errors=False, record_on_errors=False, **kwargs):
url = url.strip()
if Template.is_template_string(url):
@ -122,6 +144,19 @@ def call_webservice(url, qs_data=None, request_signature_key=None,
if cache is True and request and hasattr(request, 'wscalls_cache'):
request.wscalls_cache[unsigned_url] = (status, data)
app_error_code = get_app_error_code(response, data, 'json')
if (app_error_code != 0 or status >= 400) and (notify_on_errors or record_on_errors):
summary = '<no response>'
if response is not None:
summary = '%s %s' % (status, response.reason)
try:
raise Exception(summary)
except Exception:
exc_info = sys.exc_info()
get_publisher().notify_of_exception(
exc_info, context=['WSCALL'], notify=notify_on_errors, record=record_on_errors)
return (response, status, data)
@ -133,10 +168,18 @@ class NamedWsCall(XmlStorableObject):
slug = None
description = None
request = None
notify_on_errors = False
record_on_errors = False
# declarations for serialization
XML_NODES = [('name', 'str'), ('slug', 'str'), ('description', 'str'),
('request', 'request'),]
XML_NODES = [
('name', 'str'),
('slug', 'str'),
('description', 'str'),
('request', 'request'),
('notify_on_errors', 'bool'),
('record_on_errors', 'bool'),
]
def __init__(self, name=None):
XmlStorableObject.__init__(self)
@ -205,7 +248,11 @@ class NamedWsCall(XmlStorableObject):
return {'webservice': WsCallsSubstitutionProxy()}
def call(self):
(response, status, data) = call_webservice(cache=True, **self.request)
(response, status, data) = call_webservice(
cache=True,
notify_on_errors=self.notify_on_errors,
record_on_errors=self.record_on_errors,
**self.request)
return json_loads(data)