File: test_resilientsession.py

package info (click to toggle)
python-jira 3.9.4-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,008 kB
  • sloc: python: 8,643; sh: 13; makefile: 7; xml: 4
file content (244 lines) | stat: -rw-r--r-- 9,794 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
from __future__ import annotations

import logging
from http import HTTPStatus
from unittest.mock import Mock, patch

import pytest
from requests import Response

import jira.resilientsession
from jira.exceptions import JIRAError
from jira.resilientsession import parse_error_msg, parse_errors
from tests.conftest import JiraTestCase


class ListLoggingHandler(logging.Handler):
    """A logging handler that records all events in a list."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.records = []

    def emit(self, record):
        self.records.append(record)

    def reset(self):
        self.records = []


class ResilientSessionLoggingConfidentialityTests(JiraTestCase):
    """No sensitive data shall be written to the log."""

    def setUp(self):
        self.loggingHandler = ListLoggingHandler()
        jira.resilientsession.logging.getLogger().addHandler(self.loggingHandler)

    def test_logging_with_connection_error(self):
        """No sensitive data shall be written to the log in case of a connection error."""
        witness = "etwhpxbhfniqnbbjoqvw"  # random string; hopefully unique
        for max_retries in (0, 1):
            for verb in ("get", "post", "put", "delete", "head", "patch", "options"):
                with self.subTest(max_retries=max_retries, verb=verb):
                    with jira.resilientsession.ResilientSession() as session:
                        session.max_retries = max_retries
                        session.max_retry_delay = 0
                        try:
                            getattr(session, verb)(
                                "http://127.0.0.1:9",
                                headers={"sensitive_header": witness},
                                data={"sensitive_data": witness},
                            )
                        except jira.resilientsession.ConnectionError:
                            pass
                    # check that `witness` does not appear in log
                    for record in self.loggingHandler.records:
                        self.assertNotIn(witness, record.msg)
                        for arg in record.args:
                            self.assertNotIn(witness, str(arg))
                        self.assertNotIn(witness, str(record))
                    self.loggingHandler.reset()

    def tearDown(self):
        jira.resilientsession.logging.getLogger().removeHandler(self.loggingHandler)
        del self.loggingHandler


# Retry test data tuples: (status_code, with_rate_limit_header, with_retry_after_header, retry_expected)
with_rate_limit = True
with_retry_after = 1
without_rate_limit = False
without_retry_after = None
status_codes_retries_test_data = [
    # Always retry 429 responses
    (HTTPStatus.TOO_MANY_REQUESTS, with_rate_limit, with_retry_after, True),
    (HTTPStatus.TOO_MANY_REQUESTS, with_rate_limit, 0, True),
    (HTTPStatus.TOO_MANY_REQUESTS, with_rate_limit, without_retry_after, True),
    (HTTPStatus.TOO_MANY_REQUESTS, without_rate_limit, with_retry_after, True),
    (HTTPStatus.TOO_MANY_REQUESTS, without_rate_limit, 0, True),
    (HTTPStatus.TOO_MANY_REQUESTS, without_rate_limit, without_retry_after, True),
    # Retry 503 responses only when 'Retry-After' in headers
    (HTTPStatus.SERVICE_UNAVAILABLE, with_rate_limit, with_retry_after, True),
    (HTTPStatus.SERVICE_UNAVAILABLE, with_rate_limit, without_retry_after, False),
    (HTTPStatus.SERVICE_UNAVAILABLE, without_rate_limit, with_retry_after, True),
    (HTTPStatus.SERVICE_UNAVAILABLE, without_rate_limit, without_retry_after, False),
    # Never retry other responses
    (HTTPStatus.UNAUTHORIZED, with_rate_limit, with_retry_after, False),
    (HTTPStatus.UNAUTHORIZED, without_rate_limit, without_retry_after, False),
    (HTTPStatus.FORBIDDEN, with_rate_limit, with_retry_after, False),
    (HTTPStatus.FORBIDDEN, without_rate_limit, without_retry_after, False),
    (HTTPStatus.NOT_FOUND, with_rate_limit, with_retry_after, False),
    (HTTPStatus.NOT_FOUND, without_rate_limit, without_retry_after, False),
    (HTTPStatus.BAD_GATEWAY, with_rate_limit, with_retry_after, False),
    (HTTPStatus.BAD_GATEWAY, without_rate_limit, without_retry_after, False),
    (HTTPStatus.GATEWAY_TIMEOUT, with_rate_limit, with_retry_after, False),
    (HTTPStatus.GATEWAY_TIMEOUT, without_rate_limit, without_retry_after, False),
]


@patch("requests.Session.request")
@patch(f"{jira.resilientsession.__name__}.time.sleep")
@pytest.mark.parametrize(
    "status_code,with_rate_limit_header,with_retry_after_header,retry_expected",
    status_codes_retries_test_data,
)
def test_status_codes_retries(
    mocked_sleep_method: Mock,
    mocked_request_method: Mock,
    status_code: int,
    with_rate_limit_header: bool,
    with_retry_after_header: int | None,
    retry_expected: bool,
):
    RETRY_AFTER_SECONDS = with_retry_after_header or 0
    RETRY_AFTER_HEADER = {"Retry-After": f"{RETRY_AFTER_SECONDS}"}
    RATE_LIMIT_HEADERS = {
        "X-RateLimit-FillRate": "1",
        "X-RateLimit-Interval-Seconds": "1",
        "X-RateLimit-Limit": "1",
    }

    max_retries = 2

    if retry_expected:
        expected_number_of_requests = 1 + max_retries
        expected_number_of_sleep_invocations = max_retries
    else:
        expected_number_of_requests = 1
        expected_number_of_sleep_invocations = 0

    mocked_response: Response = Response()
    mocked_response.status_code = status_code
    if with_retry_after_header is not None:
        mocked_response.headers.update(RETRY_AFTER_HEADER)
    if with_rate_limit_header:
        mocked_response.headers.update(RATE_LIMIT_HEADERS)

    mocked_request_method.return_value = mocked_response

    session: jira.resilientsession.ResilientSession = (
        jira.resilientsession.ResilientSession(max_retries=max_retries)
    )

    with pytest.raises(JIRAError):
        session.get("mocked_url")

    assert mocked_request_method.call_count == expected_number_of_requests
    assert mocked_sleep_method.call_count == expected_number_of_sleep_invocations

    for actual_sleep in (
        call_args.args[0] for call_args in mocked_sleep_method.call_args_list
    ):
        assert actual_sleep >= RETRY_AFTER_SECONDS


errors_parsing_test_data = [
    (403, {"x-authentication-denied-reason": "err1"}, "", ["err1"]),
    (500, {}, "err1", ["err1"]),
    (500, {}, '{"message": "err1"}', ["err1"]),
    (500, {}, '{"errorMessages": "err1"}', ["err1"]),
    (500, {}, '{"errorMessages": ["err1", "err2"]}', ["err1", "err2"]),
    (500, {}, '{"errors": {"code1": "err1", "code2": "err2"}}', ["err1", "err2"]),
    (
        500,
        {},
        '{"errorMessages": [], "errors": {"code1": "err1", "code2": "err2"}}',
        ["err1", "err2"],
    ),
]


@pytest.mark.parametrize(
    "status_code,headers,content,expected_errors",
    errors_parsing_test_data,
)
def test_error_parsing(status_code, headers, content, expected_errors):
    mocked_response: Response = Response()
    mocked_response.status_code = status_code
    mocked_response.headers.update(headers)
    mocked_response._content = content.encode("utf-8")
    errors = parse_errors(mocked_response)
    assert errors == expected_errors
    error_msg = parse_error_msg(mocked_response)
    assert error_msg == ", ".join(expected_errors)


def test_passthrough_class():
    # GIVEN: The passthrough class and a dict of request args
    passthrough_class = jira.resilientsession.PassthroughRetryPrepare()
    my_kwargs = {"nice": "arguments"}
    # WHEN: the dict of request args are prepared
    # THEN: The exact same dict is returned
    assert passthrough_class.prepare(my_kwargs) is my_kwargs


@patch("requests.Session.request")
def test_unspecified_body_remains_unspecified(mocked_request_method: Mock):
    # Disable retries for this test.
    session = jira.resilientsession.ResilientSession(max_retries=0)
    # Data is not specified here.
    session.get(url="mocked_url")
    kwargs = mocked_request_method.call_args.kwargs
    assert "data" not in kwargs


@patch("requests.Session.request")
def test_nonempty_body_is_forwarded(mocked_request_method: Mock):
    # Disable retries for this test.
    session = jira.resilientsession.ResilientSession(max_retries=0)
    session.get(url="mocked_url", data={"some": "fake-data"})
    kwargs = mocked_request_method.call_args.kwargs
    assert kwargs["data"] == '{"some": "fake-data"}'


@patch("requests.Session.request")
def test_with_requests_simple_timeout(mocked_request_method: Mock):
    # Disable retries for this test.
    session = jira.resilientsession.ResilientSession(max_retries=0, timeout=1)
    session.get(url="mocked_url", data={"some": "fake-data"})
    kwargs = mocked_request_method.call_args.kwargs
    assert kwargs["data"] == '{"some": "fake-data"}'


@patch("requests.Session.request")
def test_with_requests_tuple_timeout(mocked_request_method: Mock):
    # Disable retries for this test.
    session = jira.resilientsession.ResilientSession(max_retries=0, timeout=(1, 3.5))
    session.get(url="mocked_url", data={"some": "fake-data"})
    kwargs = mocked_request_method.call_args.kwargs
    assert kwargs["data"] == '{"some": "fake-data"}'


@patch("requests.Session.request")
def test_verify_is_forwarded(mocked_request_method: Mock):
    # Disable retries for this test.
    session = jira.resilientsession.ResilientSession(max_retries=0)

    session.get(url="mocked_url", data={"some": "fake-data"})
    kwargs = mocked_request_method.call_args.kwargs
    assert kwargs["verify"] == session.verify is True

    session.verify = False
    session.get(url="mocked_url", data={"some": "fake-data"})
    kwargs = mocked_request_method.call_args.kwargs
    assert kwargs["verify"] == session.verify is False