Skip to content

Commit b6aa09b

Browse files
committed
Handle empty response from APM Server's config endpoint more gracefully
also, add a bunch of tests that went forgotten in the original PR fixes #562 closes #563
1 parent 3047a3c commit b6aa09b

File tree

4 files changed

+123
-6
lines changed

4 files changed

+123
-6
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v5.1.0...master)
5+
6+
### Bugfixes
7+
* fixed an issue with empty responses from APM Server's config endpoint (#562, #563)
8+
39
## v5.1.0
410
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v5.0.0...v5.1.0)
511

elasticapm/transport/http.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ def send(self, data):
106106
response.close()
107107

108108
def get_config(self, current_version=None, keys=None):
109+
"""
110+
Gets configuration from a remote APM Server
111+
112+
:param current_version: version of the current configuration
113+
:param keys: a JSON-serializable dict to identify this instance, e.g.
114+
{
115+
"service": {
116+
"name": "foo",
117+
"environment": "bar"
118+
}
119+
}
120+
:return: a three-tuple of new version, config dictionary and validity in seconds.
121+
Any element of the tuple can be None.
122+
"""
109123
url = self._config_url
110124
data = json_encoder.dumps(keys).encode("utf-8")
111125
headers = self._headers.copy()
@@ -124,14 +138,18 @@ def get_config(self, current_version=None, keys=None):
124138
try:
125139
max_age = int(next(re.finditer(r"max-age=(\d+)", response.headers["Cache-Control"])).groups()[0])
126140
except StopIteration:
127-
logger.debug("Could not parse Cache-Control header: %s", response["Cache-Control"])
141+
logger.debug("Could not parse Cache-Control header: %s", response.headers["Cache-Control"])
128142
if response.status == 304:
129143
# config is unchanged, return
130144
logger.debug("Configuration unchanged")
131145
return current_version, None, max_age
132146
elif response.status >= 400:
133147
return None, None, max_age
134148

149+
if not body:
150+
logger.debug("APM Server answered with empty body and status code %s", response.status)
151+
return current_version, None, max_age
152+
135153
return response.headers.get("Etag"), json_encoder.loads(body.decode("utf-8")), max_age
136154

137155
@property

elasticapm/transport/http_base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,16 @@ def get_config(self, current_version=None, keys=None):
7575
"""
7676
Gets configuration from a remote APM Server
7777
78+
:param current_version: version of the current configuration
7879
:param keys: a JSON-serializable dict to identify this instance, e.g.
7980
{
8081
"service": {
8182
"name": "foo",
8283
"environment": "bar"
8384
}
8485
}
85-
:param current_version: version of the current configuration
86-
:return: dictionary or None
86+
:return: a three-tuple of new version, config dictionary and validity in seconds.
87+
Any element of the tuple can be None.
8788
"""
8889
raise NotImplementedError()
8990

tests/transports/test_urllib3.py

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from urllib3.exceptions import MaxRetryError, TimeoutError
3838
from urllib3_mock import Responses
3939

40+
from elasticapm.conf import constants
4041
from elasticapm.transport.base import TransportException
4142
from elasticapm.transport.http import Transport
4243
from elasticapm.utils import compat
@@ -215,8 +216,99 @@ def test_ssl_cert_pinning_fails(waiting_httpsserver):
215216
transport = Transport(
216217
url, server_cert=os.path.join(os.path.dirname(__file__), "wrong_cert.pem"), verify_server_cert=True
217218
)
218-
with pytest.raises(TransportException) as exc_info:
219-
transport.send(compat.b("x"))
220-
transport.close()
219+
try:
220+
with pytest.raises(TransportException) as exc_info:
221+
transport.send(compat.b("x"))
222+
finally:
223+
transport.close()
221224

222225
assert "Fingerprints did not match" in exc_info.value.args[0]
226+
227+
228+
def test_config_url():
229+
transport = Transport("http://example.com/" + constants.EVENTS_API_PATH)
230+
try:
231+
assert transport._config_url == "http://example.com/" + constants.AGENT_CONFIG_PATH
232+
finally:
233+
transport.close()
234+
235+
236+
def test_get_config(waiting_httpserver):
237+
waiting_httpserver.serve_content(
238+
code=200, content=b'{"x": "y"}', headers={"Cache-Control": "max-age=5", "Etag": "2"}
239+
)
240+
url = waiting_httpserver.url
241+
transport = Transport(url + "/" + constants.EVENTS_API_PATH)
242+
try:
243+
version, data, max_age = transport.get_config("1", {})
244+
assert version == "2"
245+
assert data == {"x": "y"}
246+
assert max_age == 5
247+
finally:
248+
transport.close()
249+
250+
251+
@mock.patch("urllib3.poolmanager.PoolManager.urlopen")
252+
def test_get_config_handle_exception(mock_urlopen, caplog):
253+
transport = Transport("http://example.com/" + constants.EVENTS_API_PATH)
254+
mock_urlopen.side_effect = urllib3.exceptions.RequestError(transport.http, "http://example.com/", "boom")
255+
try:
256+
with caplog.at_level("DEBUG", "elasticapm.transport.http"):
257+
version, data, max_age = transport.get_config("1", {})
258+
assert version == "1"
259+
assert max_age == 300
260+
record = caplog.records[-1]
261+
assert "HTTP error" in record.msg
262+
finally:
263+
transport.close()
264+
265+
266+
def test_get_config_cache_headers_304(waiting_httpserver, caplog):
267+
waiting_httpserver.serve_content(code=304, content=b"", headers={"Cache-Control": "max-age=5"})
268+
url = waiting_httpserver.url
269+
transport = Transport(url + "/" + constants.EVENTS_API_PATH)
270+
try:
271+
with caplog.at_level("DEBUG", "elasticapm.transport.http"):
272+
version, data, max_age = transport.get_config("1", {})
273+
assert waiting_httpserver.requests[0].headers["If-None-Match"] == "1"
274+
assert version == "1"
275+
assert data is None
276+
assert max_age == 5
277+
record = caplog.records[-1]
278+
assert "Configuration unchanged" in record.msg
279+
finally:
280+
transport.close()
281+
282+
283+
def test_get_config_bad_cache_control_header(waiting_httpserver, caplog):
284+
waiting_httpserver.serve_content(
285+
code=200, content=b'{"x": "y"}', headers={"Cache-Control": "max-age=fifty", "Etag": "2"}
286+
)
287+
url = waiting_httpserver.url
288+
transport = Transport(url + "/" + constants.EVENTS_API_PATH)
289+
try:
290+
with caplog.at_level("DEBUG", "elasticapm.transport.http"):
291+
version, data, max_age = transport.get_config("1", {})
292+
assert version == "2"
293+
assert data == {"x": "y"}
294+
assert max_age == 300
295+
record = caplog.records[-1]
296+
assert record.message == "Could not parse Cache-Control header: max-age=fifty"
297+
finally:
298+
transport.close()
299+
300+
301+
def test_get_config_empty_response(waiting_httpserver, caplog):
302+
waiting_httpserver.serve_content(code=200, content=b"", headers={"Cache-Control": "max-age=5"})
303+
url = waiting_httpserver.url
304+
transport = Transport(url + "/" + constants.EVENTS_API_PATH)
305+
try:
306+
with caplog.at_level("DEBUG", "elasticapm.transport.http"):
307+
version, data, max_age = transport.get_config("1", {})
308+
assert version == "1"
309+
assert data is None
310+
assert max_age == 5
311+
record = caplog.records[-1]
312+
assert record.message == "APM Server answered with empty body and status code 200"
313+
finally:
314+
transport.close()

0 commit comments

Comments
 (0)