Projet

Général

Profil

0001-wscall-notify-and-record-errors-on-failure-44050.patch

Lauréline Guérin, 15 septembre 2020 14:06

Télécharger (16,4 ko)

Voir les différences:

Subject: [PATCH] wscall: notify and record errors on failure (#44050)

 tests/test_workflows.py                  |  2 +-
 tests/test_wscall.py                     | 58 +++++++++++++++++++++-
 tests/test_wscalls_admin_pages.py        | 19 +++++++-
 tests/utilities.py                       |  1 +
 wcs/admin/wscalls.py                     | 12 +++++
 wcs/publisher.py                         | 10 ++--
 wcs/templates/wcs/backoffice/wscall.html |  7 +++
 wcs/wf/wscall.py                         | 18 +------
 wcs/wscalls.py                           | 61 +++++++++++++++++++++---
 9 files changed, 158 insertions(+), 30 deletions(-)
tests/test_workflows.py
2368 2368
    with mock.patch('wcs.wscalls.get_secret_and_orig') as mocked_secret_and_orig:
2369 2369
        mocked_secret_and_orig.return_value = ('secret', 'localhost')
2370 2370
        with mock.patch('wcs.qommon.misc._http_request') as mocked_http_post:
2371
            mocked_http_post.return_value = ('response', '200', 'data', 'headers')
2371
            mocked_http_post.return_value = (mock.Mock(headers={}), 200, 'data', 'headers')
2372 2372
            item.perform(formdata)
2373 2373
            url = mocked_http_post.call_args[0][0]
2374 2374
            payload = mocked_http_post.call_args[1]['body']
tests/test_wscall.py
1 1
import json
2 2
import pytest
3 3

  
4
from wcs import fields
5
from wcs.formdef import FormDef
6
from wcs.logged_errors import LoggedError
4 7
from wcs.qommon.http_request import HTTPRequest
5 8
from wcs.qommon.template import Template
6 9
from wcs.wscalls import NamedWsCall
7 10

  
8
from utilities import create_temporary_pub, clean_temporary_pub
11
from utilities import get_app, create_temporary_pub, clean_temporary_pub
9 12

  
10 13

  
11 14
@pytest.fixture
......
165 168
        pass
166 169
    assert http_requests.get_last('url') == wscall.request['url']
167 170
    assert http_requests.get_last('method') == 'DELETE'
171

  
172

  
173
@pytest.mark.parametrize('notify_on_errors', [True, False])
174
@pytest.mark.parametrize('record_on_errors', [True, False])
175
def test_webservice_on_error(http_requests, pub, emails, notify_on_errors, record_on_errors):
176
    pub.cfg['debug'] = {'error_email': 'errors@localhost.invalid'}
177
    pub.write_cfg()
178

  
179
    NamedWsCall.wipe()
180
    LoggedError.wipe()
181
    FormDef.wipe()
182

  
183
    wscall = NamedWsCall()
184
    wscall.name = 'Hello world'
185
    wscall.notify_on_errors = notify_on_errors
186
    wscall.record_on_errors = record_on_errors
187
    wscall.store()
188
    assert wscall.slug == 'hello_world'
189

  
190
    formdef = FormDef()
191
    formdef.name = 'foobar'
192
    formdef.fields = [
193
        fields.CommentField(id='0', label='Foo Bar {{ webservice.hello_world }}', type='comment'),
194
    ]
195
    formdef.store()
196

  
197
    for url_part in ['json', 'json-err0', 'json-errheader0']:
198
        wscall.request = {'url': 'http://remote.example.net/%s' % url_part}
199
        wscall.store()
200
        resp = get_app(pub).get('/foobar/')
201
        assert 'Foo Bar ' in resp.text
202
        assert emails.count() == 0
203
        assert LoggedError.count() == 0
204

  
205
    for url_part in ['400', '400-json', '404', '404-json', '500', 'json-err1', 'json-errheader1']:
206
        status_code = 200
207
        if not url_part.startswith('json'):
208
            status_code = url_part[:3]
209
        wscall.request = {'url': 'http://remote.example.net/%s' % url_part}
210
        wscall.store()
211
        resp = get_app(pub).get('/foobar/')
212
        assert 'Foo Bar ' in resp.text
213
        if notify_on_errors:
214
            assert emails.count() == 1
215
            assert "[ERROR] ['WSCALL'] Exception: %s whatever" % status_code in emails.emails
216
            emails.empty()
217
        else:
218
            assert emails.count() == 0
219
        if record_on_errors:
220
            assert LoggedError.count() == 1
221
            LoggedError.wipe()
222
        else:
223
            assert LoggedError.count() == 0
tests/test_wscalls_admin_pages.py
44 44
    NamedWsCall.wipe()
45 45
    wscall = NamedWsCall(name='xxx')
46 46
    wscall.description = 'description'
47
    wscall.notify_on_errors = True
48
    wscall.record_on_errors = True
47 49
    wscall.request = {
48 50
            'url': 'http://remote.example.net/json',
49 51
            'request_signature_key': 'xxx',
......
55 57
    return wscall
56 58

  
57 59

  
58
def test_wscalls_new(pub):
60
@pytest.mark.parametrize('value', [True, False])
61
def test_wscalls_new(pub, value):
59 62
    create_superuser(pub)
60 63
    NamedWsCall.wipe()
61 64
    app = login(get_app(pub))
......
69 72
    # go to the page and add a webservice call
70 73
    resp = app.get('/backoffice/settings/wscalls/')
71 74
    resp = resp.click('New webservice call')
75
    assert resp.form['notify_on_errors'].value == 'yes'
76
    assert resp.form['record_on_errors'].value == 'yes'
72 77
    resp.form['name'] = 'a new webservice call'
73 78
    resp.form['description'] = 'description'
79
    resp.form['notify_on_errors'] = value
80
    resp.form['record_on_errors'] = value
74 81
    resp.form['request$url'] = 'http://remote.example.net/json'
75 82
    resp = resp.form.submit('submit')
76 83
    assert resp.location == 'http://example.net/backoffice/settings/wscalls/'
......
82 89
    assert 'Edit webservice call' in resp.text
83 90

  
84 91
    assert NamedWsCall.get('a_new_webservice_call').name == 'a new webservice call'
92
    assert NamedWsCall.get('a_new_webservice_call').notify_on_errors == value
93
    assert NamedWsCall.get('a_new_webservice_call').record_on_errors == value
85 94

  
86 95

  
87 96
def test_wscalls_view(pub, wscall):
......
100 109
    resp = app.get('/backoffice/settings/wscalls/xxx/')
101 110
    resp = resp.click(href='edit')
102 111
    assert resp.form['name'].value == 'xxx'
112
    assert resp.form['notify_on_errors'].value == 'yes'
113
    assert resp.form['record_on_errors'].value == 'yes'
103 114
    assert 'slug' in resp.form.fields
104 115
    resp.form['description'] = 'bla bla bla'
116
    resp.form['notify_on_errors'] = False
117
    resp.form['record_on_errors'] = False
105 118
    resp = resp.form.submit('submit')
106 119
    assert resp.location == 'http://example.net/backoffice/settings/wscalls/xxx/'
107 120
    resp = resp.follow()
108 121

  
109 122
    assert NamedWsCall.get('xxx').description == 'bla bla bla'
123
    assert NamedWsCall.get('xxx').notify_on_errors is False
124
    assert NamedWsCall.get('xxx').record_on_errors is False
110 125

  
111 126
    resp = app.get('/backoffice/settings/wscalls/xxx/')
112 127
    resp = resp.click(href='edit')
113 128
    assert resp.form['name'].value == 'xxx'
129
    assert resp.form['notify_on_errors'].value is None
130
    assert resp.form['record_on_errors'].value is None
114 131
    assert 'slug' in resp.form.fields
115 132
    resp.form['slug'] = 'yyy'
116 133
    resp = resp.form.submit('submit')
tests/utilities.py
324 324

  
325 325
        status, data, headers = {
326 326
            'http://remote.example.net/204': (204, None, None),
327
            'http://remote.example.net/400': (400, 'bad request', None),
327 328
            'http://remote.example.net/400-json': (400, '{"err": 1, "err_desc": ":("}', None),
328 329
            'http://remote.example.net/404': (404, 'page not found', None),
329 330
            'http://remote.example.net/404-json': (404, '{"err": 1}', None),
wcs/admin/wscalls.py
52 52
                value=self.wscall.request,
53 53
                title=_('Request'), required=True)
54 54

  
55
        form.add(
56
            CheckboxWidget,
57
            'notify_on_errors',
58
            title=_('Notify on errors'),
59
            value=self.wscall.notify_on_errors if self.wscall.slug else True)
60
        form.add(
61
            CheckboxWidget,
62
            'record_on_errors',
63
            title=_('Record on errors'),
64
            value=self.wscall.record_on_errors if self.wscall.slug else True)
55 65
        if not self.wscall.is_readonly():
56 66
            form.add_submit('submit', _('Submit'))
57 67
        form.add_submit('cancel', _('Cancel'))
......
76 86

  
77 87
        self.wscall.name = name
78 88
        self.wscall.description = form.get_widget('description').parse()
89
        self.wscall.notify_on_errors = form.get_widget('notify_on_errors').parse()
90
        self.wscall.record_on_errors = form.get_widget('record_on_errors').parse()
79 91
        self.wscall.request = form.get_widget('request').parse()
80 92
        if self.wscall.slug:
81 93
            self.wscall.slug = slug
wcs/publisher.py
317 317
        conn.commit()
318 318
        cur.close()
319 319

  
320
    def notify_of_exception(self, exc_tuple, context=None):
320
    def notify_of_exception(self, exc_tuple, context=None, record=True, notify=True):
321 321
        exc_type, exc_value, tb = exc_tuple
322 322
        error_summary = traceback.format_exception_only(exc_type, exc_value)
323
        error_summary = error_summary[0][0:-1] # de-listify and strip newline
323
        error_summary = error_summary[0][0:-1]  # de-listify and strip newline
324 324
        if context:
325 325
            error_summary = '%s %s' % (context, error_summary)
326 326

  
......
330 330
                        exc_type, exc_value,
331 331
                        tb))
332 332

  
333
        self.log_internal_error(error_summary, plain_error_msg, record=True)
333
        self.log_internal_error(error_summary, plain_error_msg, record=record, notify=notify)
334 334

  
335
    def log_internal_error(self, error_summary, plain_error_msg, record=False):
335
    def log_internal_error(self, error_summary, plain_error_msg, record=False, notify=True):
336 336
        tech_id = None
337 337
        if record:
338 338
            logged_exception = LoggedError.record_exception(
339 339
                    error_summary, plain_error_msg, publisher=self)
340 340
            if logged_exception:
341 341
                tech_id = logged_exception.tech_id
342
        if not notify:
343
            return
342 344
        try:
343 345
            self.logger.log_internal_error(error_summary, plain_error_msg, tech_id)
344 346
        except socket.error:
wcs/templates/wcs/backoffice/wscall.html
33 33
  <li>{% trans "Method:" %} {% if wscall.request.method == 'POST' %}POST{% else %}GET{% endif %}</li>
34 34
</ul>
35 35
</div>
36

  
37
<div class="bo-block">
38
<ul>
39
  <li>{% trans "Notify on errors:" %} {{ wscall.notify_on_errors|yesno }}</li>
40
  <li>{% trans "Record on errors:" %} {{ wscall.record_on_errors|yesno }}</li>
41
</ul>
42
</div>
36 43
{% endblock %}
wcs/wf/wscall.py
33 33
from ..qommon.misc import json_loads
34 34
from wcs.workflows import (WorkflowStatusItem, register_item_class,
35 35
        AbortActionException, AttachmentEvolutionPart)
36
from wcs.wscalls import call_webservice
36
from wcs.wscalls import call_webservice, get_app_error_code
37 37

  
38 38

  
39 39
class JournalWsCallErrorPart: #pylint: disable=C1001
......
306 306
                    exc_info=sys.exc_info())
307 307
            return
308 308

  
309
        app_error_code = 0
309
        app_error_code = get_app_error_code(response, data, self.response_type)
310 310
        app_error_code_header = response.headers.get('x-error-code')
311
        if app_error_code_header:
312
            # result is good only if header value is '0'
313
            try:
314
                app_error_code = int(app_error_code_header)
315
            except ValueError as e:
316
                app_error_code = app_error_code_header
317
        elif self.response_type == 'json':
318
            try:
319
                d = json_loads(data)
320
            except (ValueError, TypeError) as e:
321
                pass
322
            else:
323
                if isinstance(d, dict) and d.get('err'):
324
                    app_error_code = d['err']
325 311

  
326 312
        if self.varname:
327 313
            workflow_data.update({
wcs/wscalls.py
28 28
from .qommon import _, force_str
29 29
from .qommon.misc import simplify, get_variadic_url, JSONEncoder, json_loads
30 30
from .qommon.xml_storage import XmlStorableObject
31
from .qommon.form import (CompositeWidget, StringWidget, WidgetDict,
32
        ComputedExpressionWidget, RadiobuttonsWidget, CheckboxWidget)
31
from .qommon.form import (
32
    CompositeWidget, StringWidget, WidgetDict,
33
    ComputedExpressionWidget, RadiobuttonsWidget, CheckboxWidget)
33 34
from .qommon import misc
34 35
from .qommon.template import Template
35 36

  
......
37 38
from wcs.workflows import WorkflowStatusItem
38 39

  
39 40

  
40
def call_webservice(url, qs_data=None, request_signature_key=None,
41
def get_app_error_code(response, data, response_type):
42
    app_error_code = 0
43
    app_error_code_header = response.headers.get('x-error-code')
44
    if app_error_code_header:
45
        # result is good only if header value is '0'
46
        try:
47
            app_error_code = int(app_error_code_header)
48
        except ValueError:
49
            app_error_code = app_error_code_header
50
    elif response_type == 'json':
51
        try:
52
            d = json_loads(data)
53
        except (ValueError, TypeError):
54
            pass
55
        else:
56
            if isinstance(d, dict) and d.get('err'):
57
                app_error_code = d['err']
58
    return app_error_code
59

  
60

  
61
def call_webservice(
62
        url, qs_data=None, request_signature_key=None,
41 63
        method=None, post_data=None, post_formdata=None, formdata=None,
42
        cache=False, **kwargs):
64
        cache=False, notify_on_errors=False, record_on_errors=False, **kwargs):
43 65

  
44 66
    url = url.strip()
45 67
    if Template.is_template_string(url):
......
122 144
        if cache is True and request and hasattr(request, 'wscalls_cache'):
123 145
            request.wscalls_cache[unsigned_url] = (status, data)
124 146

  
147
    app_error_code = get_app_error_code(response, data, 'json')
148

  
149
    if (app_error_code != 0 or status >= 400) and (notify_on_errors or record_on_errors):
150
        summary = '<no response>'
151
        if response is not None:
152
            summary = '%s %s' % (status, response.reason)
153
        try:
154
            raise Exception(summary)
155
        except Exception:
156
            exc_info = sys.exc_info()
157
        get_publisher().notify_of_exception(
158
            exc_info, context=['WSCALL'], notify=notify_on_errors, record=record_on_errors)
159

  
125 160
    return (response, status, data)
126 161

  
127 162

  
......
133 168
    slug = None
134 169
    description = None
135 170
    request = None
171
    notify_on_errors = False
172
    record_on_errors = False
136 173

  
137 174
    # declarations for serialization
138
    XML_NODES = [('name', 'str'), ('slug', 'str'), ('description', 'str'),
139
            ('request', 'request'),]
175
    XML_NODES = [
176
        ('name', 'str'),
177
        ('slug', 'str'),
178
        ('description', 'str'),
179
        ('request', 'request'),
180
        ('notify_on_errors', 'bool'),
181
        ('record_on_errors', 'bool'),
182
    ]
140 183

  
141 184
    def __init__(self, name=None):
142 185
        XmlStorableObject.__init__(self)
......
205 248
        return {'webservice': WsCallsSubstitutionProxy()}
206 249

  
207 250
    def call(self):
208
        (response, status, data) = call_webservice(cache=True, **self.request)
251
        (response, status, data) = call_webservice(
252
            cache=True,
253
            notify_on_errors=self.notify_on_errors,
254
            record_on_errors=self.record_on_errors,
255
            **self.request)
209 256
        return json_loads(data)
210 257

  
211 258

  
212
-