[7.x] Rename product error to 'UnsupportedProductError'

This commit is contained in:
Seth Michael Larson
2021-07-20 11:38:41 -05:00
committed by GitHub
parent de52e149a5
commit d381491482
6 changed files with 211 additions and 99 deletions
+5 -9
View File
@@ -27,11 +27,10 @@ from ..exceptions import (
ConnectionError, ConnectionError,
ConnectionTimeout, ConnectionTimeout,
ElasticsearchWarning, ElasticsearchWarning,
NotElasticsearchError,
SerializationError, SerializationError,
TransportError, TransportError,
) )
from ..transport import Transport, _verify_elasticsearch from ..transport import Transport, _ProductChecker
from .compat import get_running_loop from .compat import get_running_loop
from .http_aiohttp import AIOHttpConnection from .http_aiohttp import AIOHttpConnection
@@ -340,12 +339,9 @@ class AsyncTransport(Transport):
if self._verified_elasticsearch is None: if self._verified_elasticsearch is None:
await self._do_verify_elasticsearch(headers=headers, timeout=timeout) await self._do_verify_elasticsearch(headers=headers, timeout=timeout)
# If '_verified_elasticsearch' is False we know we're not connected to Elasticsearch. # If '_verified_elasticsearch' isn't 'True' then we raise an error.
if self._verified_elasticsearch is False: if self._verified_elasticsearch is not True:
raise NotElasticsearchError( _ProductChecker.raise_error(self._verified_elasticsearch)
"The client noticed that the server is not Elasticsearch "
"and we do not support this unknown product"
)
for attempt in range(self.max_retries + 1): for attempt in range(self.max_retries + 1):
connection = self.get_connection() connection = self.get_connection()
@@ -496,6 +492,6 @@ class AsyncTransport(Transport):
raise error raise error
# Check the information we got back from the index request. # Check the information we got back from the index request.
self._verified_elasticsearch = _verify_elasticsearch( self._verified_elasticsearch = _ProductChecker.check_product(
info_headers, info_response info_headers, info_response
) )
+2 -2
View File
@@ -51,9 +51,9 @@ class SerializationError(ElasticsearchException):
""" """
class NotElasticsearchError(ElasticsearchException): class UnsupportedProductError(ElasticsearchException):
"""Error which is raised when the client detects """Error which is raised when the client detects
it's not connected to an Elasticsearch cluster. it's not connected to a supported product.
""" """
+1 -1
View File
@@ -20,7 +20,7 @@ from typing import Any, Dict, Union
class ImproperlyConfigured(Exception): ... class ImproperlyConfigured(Exception): ...
class ElasticsearchException(Exception): ... class ElasticsearchException(Exception): ...
class SerializationError(ElasticsearchException): ... class SerializationError(ElasticsearchException): ...
class NotElasticsearchError(ElasticsearchException): ... class UnsupportedProductError(ElasticsearchException): ...
class TransportError(ElasticsearchException): class TransportError(ElasticsearchException):
@property @property
+80 -52
View File
@@ -31,9 +31,9 @@ from .exceptions import (
ConnectionError, ConnectionError,
ConnectionTimeout, ConnectionTimeout,
ElasticsearchWarning, ElasticsearchWarning,
NotElasticsearchError,
SerializationError, SerializationError,
TransportError, TransportError,
UnsupportedProductError,
) )
from .serializer import DEFAULT_SERIALIZERS, Deserializer, JSONSerializer from .serializer import DEFAULT_SERIALIZERS, Deserializer, JSONSerializer
from .utils import _client_meta_version from .utils import _client_meta_version
@@ -214,8 +214,8 @@ class Transport(object):
# - 'True': Means we've verified that we're talking to Elasticsearch or # - 'True': Means we've verified that we're talking to Elasticsearch or
# that we can't rule out Elasticsearch due to auth issues. A warning # that we can't rule out Elasticsearch due to auth issues. A warning
# will be raised if we receive 401/403. # will be raised if we receive 401/403.
# - 'False': Means we've discovered we're not talking to Elasticsearch, # - 'int': Means we're talking to an unsupported product, should raise
# should raise an error in this case for every request. # the corresponding error.
self._verified_elasticsearch = None self._verified_elasticsearch = None
# Ensures that the ES verification request only fires once and that # Ensures that the ES verification request only fires once and that
@@ -408,12 +408,9 @@ class Transport(object):
if self._verified_elasticsearch is None: if self._verified_elasticsearch is None:
self._do_verify_elasticsearch(headers=headers, timeout=timeout) self._do_verify_elasticsearch(headers=headers, timeout=timeout)
# If '_verified_elasticsearch' is False we know we're not connected to Elasticsearch. # If '_verified_elasticsearch' isn't 'True' then we raise an error.
if self._verified_elasticsearch is False: if self._verified_elasticsearch is not True:
raise NotElasticsearchError( _ProductChecker.raise_error(self._verified_elasticsearch)
"The client noticed that the server is not Elasticsearch "
"and we do not support this unknown product"
)
for attempt in range(self.max_retries + 1): for attempt in range(self.max_retries + 1):
connection = self.get_connection() connection = self.get_connection()
@@ -601,53 +598,84 @@ class Transport(object):
raise error raise error
# Check the information we got back from the index request. # Check the information we got back from the index request.
self._verified_elasticsearch = _verify_elasticsearch( self._verified_elasticsearch = _ProductChecker.check_product(
info_headers, info_response info_headers, info_response
) )
def _verify_elasticsearch(headers, response): class _ProductChecker:
"""Verifies that the server we're talking to is Elasticsearch. """Class which verifies we're connected to a supported product"""
Does this by checking HTTP headers and the deserialized
response to the 'info' API. Returns 'True' if we're verified
against Elasticsearch, 'False' otherwise.
"""
try:
version = response.get("version", {})
version_number = tuple(
int(x) if x is not None else 999
for x in re.search(
r"^([0-9]+)\.([0-9]+)(?:\.([0-9]+))?", version["number"]
).groups()
)
except (KeyError, TypeError, ValueError, AttributeError):
# No valid 'version.number' field, effectively 0.0.0
version = {}
version_number = (0, 0, 0)
# Check all of the fields and headers for missing/valid values. # States that can be returned from 'check_product'
try: SUCCESS = True
bad_tagline = response.get("tagline", None) != "You Know, for Search" UNSUPPORTED_PRODUCT = 2
bad_build_flavor = version.get("build_flavor", None) != "default" UNSUPPORTED_DISTRIBUTION = 3
bad_product_header = headers.get("x-elastic-product", None) != "Elasticsearch"
except (AttributeError, TypeError):
bad_tagline = True
bad_build_flavor = True
bad_product_header = True
if ( @classmethod
# No version or version less than 6.x def raise_error(cls, state):
version_number < (6, 0, 0) # These states mean the product_check() didn't fail so do nothing.
# 6.x and there's a bad 'tagline' if state in (None, True):
or ((6, 0, 0) <= version_number < (7, 0, 0) and bad_tagline) return
# 7.0-7.13 and there's a bad 'tagline' or 'build_flavor'
or (
(7, 0, 0) <= version_number < (7, 14, 0)
and (bad_tagline or bad_build_flavor)
)
# 7.14+ and there's a bad 'X-Elastic-Product' HTTP header
or ((7, 14, 0) <= version_number and bad_product_header)
):
return False
return True if state == cls.UNSUPPORTED_DISTRIBUTION:
message = (
"The client noticed that the server is not "
"a supported distribution of Elasticsearch"
)
else: # UNSUPPORTED_PRODUCT
message = (
"The client noticed that the server is not Elasticsearch "
"and we do not support this unknown product"
)
raise UnsupportedProductError(message)
@classmethod
def check_product(cls, headers, response):
# type: (dict[str, str], dict[str, str]) -> int
"""Verifies that the server we're talking to is Elasticsearch.
Does this by checking HTTP headers and the deserialized
response to the 'info' API. Returns one of the states above.
"""
try:
version = response.get("version", {})
version_number = tuple(
int(x) if x is not None else 999
for x in re.search(
r"^([0-9]+)\.([0-9]+)(?:\.([0-9]+))?", version["number"]
).groups()
)
except (KeyError, TypeError, ValueError, AttributeError):
# No valid 'version.number' field, effectively 0.0.0
version = {}
version_number = (0, 0, 0)
# Check all of the fields and headers for missing/valid values.
try:
bad_tagline = response.get("tagline", None) != "You Know, for Search"
bad_build_flavor = version.get("build_flavor", None) != "default"
bad_product_header = (
headers.get("x-elastic-product", None) != "Elasticsearch"
)
except (AttributeError, TypeError):
bad_tagline = True
bad_build_flavor = True
bad_product_header = True
# 7.0-7.13 and there's a bad 'tagline' or unsupported 'build_flavor'
if (7, 0, 0) <= version_number < (7, 14, 0):
if bad_tagline:
return cls.UNSUPPORTED_PRODUCT
elif bad_build_flavor:
return cls.UNSUPPORTED_DISTRIBUTION
elif (
# No version or version less than 6.x
version_number < (6, 0, 0)
# 6.x and there's a bad 'tagline'
or ((6, 0, 0) <= version_number < (7, 0, 0) and bad_tagline)
# 7.14+ and there's a bad 'X-Elastic-Product' HTTP header
or ((7, 14, 0) <= version_number and bad_product_header)
):
return cls.UNSUPPORTED_PRODUCT
return True
@@ -33,10 +33,11 @@ from elasticsearch.exceptions import (
AuthorizationException, AuthorizationException,
ConnectionError, ConnectionError,
ElasticsearchWarning, ElasticsearchWarning,
NotElasticsearchError,
NotFoundError, NotFoundError,
TransportError, TransportError,
UnsupportedProductError,
) )
from elasticsearch.transport import _ProductChecker
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@@ -641,7 +642,7 @@ class TestTransport:
[{"data": data, "headers": headers}], connection_class=DummyConnection [{"data": data, "headers": headers}], connection_class=DummyConnection
) )
await t.perform_request("GET", "/_search") await t.perform_request("GET", "/_search")
assert t._verified_elasticsearch assert t._verified_elasticsearch is True
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
_ = [call[1]["headers"].pop("x-elastic-client-meta") for call in calls] _ = [call[1]["headers"].pop("x-elastic-client-meta") for call in calls]
@@ -688,7 +689,7 @@ class TestTransport:
] ]
# Assert that the cluster is "verified" # Assert that the cluster is "verified"
assert t._verified_elasticsearch assert t._verified_elasticsearch is True
# See that the headers were passed along to the "info" request made # See that the headers were passed along to the "info" request made
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
@@ -762,7 +763,7 @@ class TestTransport:
) )
# Assert that the cluster is "verified" # Assert that the cluster is "verified"
assert t._verified_elasticsearch assert t._verified_elasticsearch is True
# See that the first request is always 'GET /' for ES check # See that the first request is always 'GET /' for ES check
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
@@ -771,13 +772,37 @@ class TestTransport:
# The rest of the requests are 'GET /_search' afterwards # The rest of the requests are 'GET /_search' afterwards
assert all(call[0][:2] == ("GET", "/_search") for call in calls[1:]) assert all(call[0][:2] == ("GET", "/_search") for call in calls[1:])
@pytest.mark.parametrize(
["build_flavor", "tagline", "product_error", "error_message"],
[
(
"default",
"BAD TAGLINE",
_ProductChecker.UNSUPPORTED_PRODUCT,
"The client noticed that the server is not Elasticsearch and we do not support this unknown product",
),
(
"BAD BUILD FLAVOR",
"BAD TAGLINE",
_ProductChecker.UNSUPPORTED_PRODUCT,
"The client noticed that the server is not Elasticsearch and we do not support this unknown product",
),
(
"BAD BUILD FLAVOR",
"You Know, for Search",
_ProductChecker.UNSUPPORTED_DISTRIBUTION,
"The client noticed that the server is not a supported distribution of Elasticsearch",
),
],
)
async def test_multiple_requests_verify_elasticsearch_product_error( async def test_multiple_requests_verify_elasticsearch_product_error(
self, event_loop self, event_loop, build_flavor, tagline, product_error, error_message
): ):
t = AsyncTransport( t = AsyncTransport(
[ [
{ {
"data": '{"version":{"number":"7.13.0","build_flavor":"default"},"tagline":"BAD TAGLINE"}', "data": '{"version":{"number":"7.13.0","build_flavor":"%s"},"tagline":"%s"}'
% (build_flavor, tagline),
"delay": 1, "delay": 1,
} }
], ],
@@ -806,7 +831,8 @@ class TestTransport:
assert len(results) == 10 assert len(results) == 10
# All results were errors # All results were errors
assert all(isinstance(result, NotElasticsearchError) for result in results) assert all(isinstance(result, UnsupportedProductError) for result in results)
assert all(str(result) == error_message for result in results)
# Assert that one request was made but not 2 requests. # Assert that one request was made but not 2 requests.
duration = end_time - start_time duration = end_time - start_time
@@ -818,7 +844,7 @@ class TestTransport:
) )
# Assert that the cluster is definitely not Elasticsearch # Assert that the cluster is definitely not Elasticsearch
assert t._verified_elasticsearch is False assert t._verified_elasticsearch == product_error
# See that the first request is always 'GET /' for ES check # See that the first request is always 'GET /' for ES check
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
+89 -27
View File
@@ -31,13 +31,11 @@ from elasticsearch.exceptions import (
AuthorizationException, AuthorizationException,
ConnectionError, ConnectionError,
ElasticsearchWarning, ElasticsearchWarning,
NotElasticsearchError,
NotFoundError, NotFoundError,
TransportError, TransportError,
UnsupportedProductError,
) )
from elasticsearch.transport import Transport from elasticsearch.transport import Transport, _ProductChecker, get_host_info
from elasticsearch.transport import _verify_elasticsearch as verify_elasticsearch
from elasticsearch.transport import get_host_info
from .test_cases import TestCase from .test_cases import TestCase
@@ -486,26 +484,50 @@ TAGLINE = "You Know, for Search"
@pytest.mark.parametrize( @pytest.mark.parametrize(
["headers", "response"], ["headers", "response", "product_error"],
[ [
# All empty. # All empty.
({}, {}), ({}, {}, _ProductChecker.UNSUPPORTED_PRODUCT),
# Don't check the product header immediately, need to check version first. # Don't check the product header immediately, need to check version first.
({"x-elastic-product": "Elasticsearch"}, {}), (
{"x-elastic-product": "Elasticsearch"},
{},
_ProductChecker.UNSUPPORTED_PRODUCT,
),
# Version not there. # Version not there.
({}, {"tagline": TAGLINE}), ({}, {"tagline": TAGLINE}, _ProductChecker.UNSUPPORTED_PRODUCT),
# Version is nonsense # Version is nonsense
({}, {"version": "1.0.0", "tagline": TAGLINE}), (
{},
{"version": "1.0.0", "tagline": TAGLINE},
_ProductChecker.UNSUPPORTED_PRODUCT,
),
# Version number not there # Version number not there
({}, {"version": {}, "tagline": TAGLINE}), ({}, {"version": {}, "tagline": TAGLINE}, _ProductChecker.UNSUPPORTED_PRODUCT),
# Version number is nonsense # Version number is nonsense
({}, {"version": {"number": "nonsense"}, "tagline": TAGLINE}), (
{},
{"version": {"number": "nonsense"}, "tagline": TAGLINE},
_ProductChecker.UNSUPPORTED_PRODUCT,
),
# Version number way in the past # Version number way in the past
({}, {"version": {"number": "1.0.0"}, "tagline": TAGLINE}), (
{},
{"version": {"number": "1.0.0"}, "tagline": TAGLINE},
_ProductChecker.UNSUPPORTED_PRODUCT,
),
# Version number way in the future # Version number way in the future
({}, {"version": {"number": "999.0.0"}, "tagline": TAGLINE}), (
{},
{"version": {"number": "999.0.0"}, "tagline": TAGLINE},
_ProductChecker.UNSUPPORTED_PRODUCT,
),
# Build flavor not supposed to be missing # Build flavor not supposed to be missing
({}, {"version": {"number": "7.13.0"}, "tagline": TAGLINE}), (
{},
{"version": {"number": "7.13.0"}, "tagline": TAGLINE},
_ProductChecker.UNSUPPORTED_DISTRIBUTION,
),
# Build flavor is 'oss' # Build flavor is 'oss'
( (
{}, {},
@@ -513,6 +535,7 @@ TAGLINE = "You Know, for Search"
"version": {"number": "7.10.0", "build_flavor": "oss"}, "version": {"number": "7.10.0", "build_flavor": "oss"},
"tagline": TAGLINE, "tagline": TAGLINE,
}, },
_ProductChecker.UNSUPPORTED_DISTRIBUTION,
), ),
# Build flavor is nonsense # Build flavor is nonsense
( (
@@ -521,20 +544,30 @@ TAGLINE = "You Know, for Search"
"version": {"number": "7.13.0", "build_flavor": "nonsense"}, "version": {"number": "7.13.0", "build_flavor": "nonsense"},
"tagline": TAGLINE, "tagline": TAGLINE,
}, },
_ProductChecker.UNSUPPORTED_DISTRIBUTION,
), ),
# Tagline is nonsense # Tagline is nonsense
({}, {"version": {"number": "7.1.0-SNAPSHOT"}, "tagline": "nonsense"}), (
{},
{"version": {"number": "7.1.0-SNAPSHOT"}, "tagline": "nonsense"},
_ProductChecker.UNSUPPORTED_PRODUCT,
),
# Product header is not supposed to be missing # Product header is not supposed to be missing
({}, {"version": {"number": "7.14.0"}, "tagline": "You Know, for Search"}), (
{},
{"version": {"number": "7.14.0"}, "tagline": "You Know, for Search"},
_ProductChecker.UNSUPPORTED_PRODUCT,
),
# Product header is nonsense # Product header is nonsense
( (
{"x-elastic-product": "nonsense"}, {"x-elastic-product": "nonsense"},
{"version": {"number": "7.15.0"}, "tagline": TAGLINE}, {"version": {"number": "7.15.0"}, "tagline": TAGLINE},
_ProductChecker.UNSUPPORTED_PRODUCT,
), ),
], ],
) )
def test_verify_elasticsearch_errors(headers, response): def test_verify_elasticsearch_errors(headers, response, product_error):
assert verify_elasticsearch(headers, response) is False assert _ProductChecker.check_product(headers, response) == product_error
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -579,7 +612,9 @@ def test_verify_elasticsearch_errors(headers, response):
], ],
) )
def test_verify_elasticsearch_passes(headers, response): def test_verify_elasticsearch_passes(headers, response):
assert verify_elasticsearch(headers, response) is True result = _ProductChecker.check_product(headers, response)
assert result == _ProductChecker.SUCCESS
assert result is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -624,7 +659,7 @@ def test_verify_elasticsearch(headers, data):
[{"data": data, "headers": headers}], connection_class=DummyConnection [{"data": data, "headers": headers}], connection_class=DummyConnection
) )
t.perform_request("GET", "/_search") t.perform_request("GET", "/_search")
assert t._verified_elasticsearch assert t._verified_elasticsearch is True
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
_ = [call[1]["headers"].pop("x-elastic-client-meta") for call in calls] _ = [call[1]["headers"].pop("x-elastic-client-meta") for call in calls]
@@ -675,7 +710,7 @@ def test_verify_elasticsearch_skips_on_auth_errors(exception_cls):
] ]
# Assert that the cluster is "verified" # Assert that the cluster is "verified"
assert t._verified_elasticsearch assert t._verified_elasticsearch is True
# See that the headers were passed along to the "info" request made # See that the headers were passed along to the "info" request made
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
@@ -759,7 +794,7 @@ def test_multiple_requests_verify_elasticsearch_success():
) )
# Assert that the cluster is "verified" # Assert that the cluster is "verified"
assert t._verified_elasticsearch assert t._verified_elasticsearch is True
# See that the first request is always 'GET /' for ES check # See that the first request is always 'GET /' for ES check
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
@@ -769,7 +804,32 @@ def test_multiple_requests_verify_elasticsearch_success():
assert all(call[0][:2] == ("GET", "/_search") for call in calls[1:]) assert all(call[0][:2] == ("GET", "/_search") for call in calls[1:])
def test_multiple_requests_verify_elasticsearch_product_error(): @pytest.mark.parametrize(
["build_flavor", "tagline", "product_error", "error_message"],
[
(
"default",
"BAD TAGLINE",
_ProductChecker.UNSUPPORTED_PRODUCT,
"The client noticed that the server is not Elasticsearch and we do not support this unknown product",
),
(
"BAD BUILD FLAVOR",
"BAD TAGLINE",
_ProductChecker.UNSUPPORTED_PRODUCT,
"The client noticed that the server is not Elasticsearch and we do not support this unknown product",
),
(
"BAD BUILD FLAVOR",
"You Know, for Search",
_ProductChecker.UNSUPPORTED_DISTRIBUTION,
"The client noticed that the server is not a supported distribution of Elasticsearch",
),
],
)
def test_multiple_requests_verify_elasticsearch_product_error(
build_flavor, tagline, product_error, error_message
):
try: try:
import threading import threading
except ImportError: except ImportError:
@@ -778,7 +838,8 @@ def test_multiple_requests_verify_elasticsearch_product_error():
t = Transport( t = Transport(
[ [
{ {
"data": '{"version":{"number":"7.13.0","build_flavor":"default"},"tagline":"BAD TAGLINE"}', "data": '{"version":{"number":"7.13.0","build_flavor":"%s"},"tagline":"%s"}'
% (build_flavor, tagline),
"delay": 1, "delay": 1,
} }
], ],
@@ -811,7 +872,8 @@ def test_multiple_requests_verify_elasticsearch_product_error():
assert len(results) == 10 assert len(results) == 10
# All results were errors # All results were errors
assert all(isinstance(result, NotElasticsearchError) for result in results) assert all(isinstance(result, UnsupportedProductError) for result in results)
assert all(str(result) == error_message for result in results)
# Assert that one request was made but not 2 requests. # Assert that one request was made but not 2 requests.
duration = end_time - start_time duration = end_time - start_time
@@ -823,7 +885,7 @@ def test_multiple_requests_verify_elasticsearch_product_error():
) )
# Assert that the cluster is definitely not Elasticsearch # Assert that the cluster is definitely not Elasticsearch
assert t._verified_elasticsearch is False assert t._verified_elasticsearch == product_error
# See that the first request is always 'GET /' for ES check # See that the first request is always 'GET /' for ES check
calls = t.connection_pool.connections[0].calls calls = t.connection_pool.connections[0].calls
@@ -875,7 +937,7 @@ def test_multiple_requests_verify_elasticsearch_retry_on_errors(error_cls):
# Exactly 5 results completed # Exactly 5 results completed
assert len(results) == 5 assert len(results) == 5
# All results were errors and not wrapped in 'NotElasticsearchError' # All results were errors and not wrapped in 'UnsupportedProductError'
assert all(isinstance(result, error_cls) for result in results) assert all(isinstance(result, error_cls) for result in results)
# Assert that 5 requests were made in total (5 transport requests per x 0.1s/conn request) # Assert that 5 requests were made in total (5 transport requests per x 0.1s/conn request)