Projet

Général

Profil

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

Lauréline Guérin, 07 septembre 2020 15:36

Télécharger (16,4 ko)

Voir les différences:

Subject: [PATCH 3/3] 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
39 39
    NamedWsCall.wipe()
40 40
    wscall = NamedWsCall(name='xxx')
41 41
    wscall.description = 'description'
42
    wscall.notify_on_errors = True
43
    wscall.record_on_errors = True
42 44
    wscall.request = {
43 45
            'url': 'http://remote.example.net/json',
44 46
            'request_signature_key': 'xxx',
......
50 52
    return wscall
51 53

  
52 54

  
53
def test_wscalls_new(pub):
55
@pytest.mark.parametrize('value', [True, False])
56
def test_wscalls_new(pub, value):
54 57
    create_superuser(pub)
55 58
    NamedWsCall.wipe()
56 59
    app = login(get_app(pub))
......
64 67
    # go to the page and add a webservice call
65 68
    resp = app.get('/backoffice/settings/wscalls/')
66 69
    resp = resp.click('New webservice call')
70
    assert resp.form['notify_on_errors'].value == 'yes'
71
    assert resp.form['record_on_errors'].value == 'yes'
67 72
    resp.form['name'] = 'a new webservice call'
68 73
    resp.form['description'] = 'description'
74
    resp.form['notify_on_errors'] = value
75
    resp.form['record_on_errors'] = value
69 76
    resp.form['request$url'] = 'http://remote.example.net/json'
70 77
    resp = resp.form.submit('submit')
71 78
    assert resp.location == 'http://example.net/backoffice/settings/wscalls/'
......
77 84
    assert 'Edit webservice call' in resp.text
78 85

  
79 86
    assert NamedWsCall.get('a_new_webservice_call').name == 'a new webservice call'
87
    assert NamedWsCall.get('a_new_webservice_call').notify_on_errors == value
88
    assert NamedWsCall.get('a_new_webservice_call').record_on_errors == value
80 89

  
81 90

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

  
104 117
    assert NamedWsCall.get('xxx').description == 'bla bla bla'
118
    assert NamedWsCall.get('xxx').notify_on_errors is False
119
    assert NamedWsCall.get('xxx').record_on_errors is False
105 120

  
106 121
    resp = app.get('/backoffice/settings/wscalls/xxx/')
107 122
    resp = resp.click(href='edit')
108 123
    assert resp.form['name'].value == 'xxx'
124
    assert resp.form['notify_on_errors'].value is None
125
    assert resp.form['record_on_errors'].value is None
109 126
    assert 'slug' in resp.form.fields
110 127
    resp.form['slug'] = 'yyy'
111 128
    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
49 49
                value=self.wscall.request,
50 50
                title=_('Request'), required=True)
51 51

  
52
        form.add(
53
            CheckboxWidget,
54
            'notify_on_errors',
55
            title=_('Notify on errors'),
56
            value=self.wscall.notify_on_errors if self.wscall.slug else True)
57
        form.add(
58
            CheckboxWidget,
59
            'record_on_errors',
60
            title=_('Record on errors'),
61
            value=self.wscall.record_on_errors if self.wscall.slug else True)
52 62
        form.add_submit('submit', _('Submit'))
53 63
        form.add_submit('cancel', _('Cancel'))
54 64
        return form
......
72 82

  
73 83
        self.wscall.name = name
74 84
        self.wscall.description = form.get_widget('description').parse()
85
        self.wscall.notify_on_errors = form.get_widget('notify_on_errors').parse()
86
        self.wscall.record_on_errors = form.get_widget('record_on_errors').parse()
75 87
        self.wscall.request = form.get_widget('request').parse()
76 88
        if self.wscall.slug:
77 89
            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
30 30
  <li>{% trans "Method:" %} {% if wscall.request.method == 'POST' %}POST{% else %}GET{% endif %}</li>
31 31
</ul>
32 32
</div>
33

  
34
<div class="bo-block">
35
<ul>
36
  <li>{% trans "Notify on errors:" %} {{ wscall.notify_on_errors|yesno }}</li>
37
  <li>{% trans "Record on errors:" %} {{ wscall.record_on_errors|yesno }}</li>
38
</ul>
39
</div>
33 40
{% 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)
......
204 247
        return {'webservice': WsCallsSubstitutionProxy()}
205 248

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

  
210 257

  
211
-