on master help sign in
[httpx] replace searx.poolrequests by searx.network

settings.yml: * outgoing.networks: * can contains network definition * propertiers: enable_http, verify, http2, max_connections, max_keepalive_connections, keepalive_expiry, local_addresses, support_ipv4, support_ipv6, proxies, max_redirects, retries * retries: 0 by default, number of times searx retries to send the HTTP request (using different IP & proxy each time) * local_addresses can be "192.168.0.1/24" (it supports IPv6) * support_ipv4 & support_ipv6: both True by default see https://github.com/searx/searx/pull/1034 * each engine can define a "network" section: * either a full network description * either reference an existing network * all HTTP requests of engine use the same HTTP configuration (it was not the case before, see proxy configuration in master)

commit14fe1779b7dda5a6233ed0e62f5b0e7754ea72fa authorAlexandre Flament <alex@al-f.net> dateMon 5 Apr 2021, 10:43 (6 years ago) parent88a96ba
31 files changed+1036−677
docs/admin/settings.rst+51−13 requirements-dev.txt+1 searx/autocomplete.py+1−1 searx/engines/__init__.py+3−3 searx/engines/duckduckgo.py+1−1 searx/engines/duckduckgo_images.py+1−1 searx/engines/gigablast.py+1−1 searx/engines/pubmed.py+1−1 searx/engines/qwant.py+1−1 searx/engines/seznam.py+1−1 searx/engines/soundcloud.py+1−1 searx/engines/spotify.py+3−2 searx/engines/wikidata.py+1−1 searx/engines/wikipedia.py+1−1 searx/engines/wolframalpha_noapi.py+1−1 searx/engines/wordnik.py+1−1 searx/engines/yggtorrent.py+1−1 searx/network/__init__.py+189 searx/network/client.py+214 searx/network/network.py+302 searx/raise_for_httperror.py → searx/network/raise_for_httperror.py searx/poolrequests.py−550 searx/search/checker/impl.py+3−3 searx/search/processors/online.py+9−13 searx/settings.yml+5−6 searx/testing.py+2−2 searx/webapp.py+2−7 searx_extra/update/update_engine_descriptions.py+3−3 tests/unit/network/__init__.py tests/unit/network/test_network.py+236 tests/unit/test_poolrequests.py−62
docs/admin/settings.rst+51−13
@@ -130,14 +130,12 @@ Global Settings
130130 request_timeout : 2.0 # default timeout in seconds, can be override by engine
131131 # max_request_timeout: 10.0 # the maximum timeout in seconds
132132 useragent_suffix : "" # informations like an email address to the administrator
133− pool_connections : 100 # Number of different hosts
134− pool_maxsize : 10 # Number of simultaneous requests by host
133+ pool_connections : 100 # Maximum number of allowable connections, or None for no limits. The default is 100.
134+ pool_maxsize : 10 # Number of allowable keep-alive connections, or None to always allow. The default is 10.
135+ enable_http2: True # See https://www.python-httpx.org/http2/
135136 # uncomment below section if you want to use a proxy
136137 # proxies:
137− # http:
138− # - http://proxy1:8080
139− # - http://proxy2:8080
140− # https:
138+ # all://:
141139 # - http://proxy1:8080
142140 # - http://proxy2:8080
143141 # uncomment below section only if you have more than one network interface
@@ -145,6 +143,7 @@ Global Settings
145143 # source_ips:
146144 # - 1.1.1.1
147145 # - 1.1.1.2
146+ # - fe80::/126
148147
149148
150149``request_timeout`` :
@@ -157,20 +156,46 @@ Global Settings
157156 Suffix to the user-agent searx uses to send requests to others engines. If an
158157 engine wish to block you, a contact info here may be useful to avoid that.
159158
160−.. _requests proxies: https://requests.readthedocs.io/en/latest/user/advanced/#proxies
161−.. _PySocks: https://pypi.org/project/PySocks/
159+``keepalive_expiry``:
160+ Number of seconds to keep a connection in the pool. By default 5.0 seconds.
161+
162+.. _httpx proxies: https://www.python-httpx.org/advanced/#http-proxying
162163
163164``proxies`` :
164− Define one or more proxies you wish to use, see `requests proxies`_.
165+ Define one or more proxies you wish to use, see `httpx proxies`_.
165166 If there are more than one proxy for one protocol (http, https),
166167 requests to the engines are distributed in a round-robin fashion.
167168
168− - Proxy: `see <https://2.python-requests.org/en/latest/user/advanced/#proxies>`__.
169− - SOCKS proxies are also supported: `see <https://2.python-requests.org/en/latest/user/advanced/#socks>`__
170−
171169``source_ips`` :
172170 If you use multiple network interfaces, define from which IP the requests must
173− be made. This parameter is ignored when ``proxies`` is set.
171+ be made. Example:
172+
173+ * ``0.0.0.0`` any local IPv4 address.
174+ * ``::`` any local IPv6 address.
175+ * ``192.168.0.1``
176+ * ``[ 192.168.0.1, 192.168.0.2 ]`` these two specific IP addresses
177+ * ``fe80::60a2:1691:e5a2:ee1f``
178+ * ``fe80::60a2:1691:e5a2:ee1f/126`` all IP addresses in this network.
179+ * ``[ 192.168.0.1, fe80::/126 ]``
180+
181+``retries`` :
182+ Number of retry in case of an HTTP error.
183+ On each retry, searx uses an different proxy and source ip.
184+
185+``retry_on_http_error`` :
186+ Retry request on some HTTP status code.
187+
188+ Example:
189+
190+ * ``true`` : on HTTP status code between 400 and 599.
191+ * ``403`` : on HTTP status code 403.
192+ * ``[403, 429]``: on HTTP status code 403 and 429.
193+
194+``enable_http2`` :
195+ Enable by default. Set to ``False`` to disable HTTP/2.
196+
197+``max_redirects`` :
198+ 30 by default. Maximum redirect before it is an error.
174199
175200
176201``locales:``
@@ -216,6 +241,13 @@ Engine settings
216241 api_key : 'apikey'
217242 disabled : True
218243 language : en_US
244+ #enable_http: False
245+ #enable_http2: False
246+ #retries: 1
247+ #retry_on_http_error: True # or 403 or [404, 429]
248+ #max_connections: 100
249+ #max_keepalive_connections: 10
250+ #keepalive_expiry: 5.0
219251 #proxies:
220252 # http:
221253 # - http://proxy1:8080
@@ -270,6 +302,12 @@ Engine settings
270302``display_error_messages`` : default ``True``
271303 When an engine returns an error, the message is displayed on the user interface.
272304
305+``network``: optional
306+ Use the network configuration from another engine.
307+ In addition, there are two default networks:
308+ * ``ipv4`` set ``local_addresses`` to ``0.0.0.0`` (use only IPv4 local addresses)
309+ * ``ipv6`` set ``local_addresses`` to ``::`` (use only IPv6 local addresses)
310+
273311.. note::
274312
275313 A few more options are possible, but they are pretty specific to some
requirements-dev.txt+1
@@ -16,3 +16,4 @@ sphinx-tabs==2.1.0
1616sphinxcontrib-programoutput==0.17
1717sphinx-autobuild==2021.3.14
1818linuxdoc==20210324
19+aiounittest==1.4.0
searx/autocomplete.py+1−1
@@ -24,7 +24,7 @@ from httpx import HTTPError
2424
2525
2626from searx import settings
27−from searx.poolrequests import get as http_get
27+from searx.network import get as http_get
2828from searx.exceptions import SearxEngineResponseException
2929
3030
searx/engines/__init__.py+3−3
@@ -27,7 +27,7 @@ from searx import settings
2727from searx import logger
2828from searx.data import ENGINES_LANGUAGES
2929from searx.exceptions import SearxEngineResponseException
30−from searx.poolrequests import get, get_proxy_cycles
30+from searx.network import get, initialize as initialize_network, set_context_network_name
3131from searx.utils import load_module, match_language, get_engine_from_settings, gen_useragent
3232
3333
@@ -89,8 +89,6 @@ def load_engine(engine_data):
8989 engine.categories = []
9090 else:
9191 engine.categories = list(map(str.strip, param_value.split(',')))
92− elif param_name == 'proxies':
93− engine.proxies = get_proxy_cycles(param_value)
9492 else:
9593 setattr(engine, param_name, param_value)
9694
@@ -289,9 +287,11 @@ def load_engines(engine_list):
289287
290288def initialize_engines(engine_list):
291289 load_engines(engine_list)
290+ initialize_network(engine_list, settings['outgoing'])
292291
293292 def engine_init(engine_name, init_fn):
294293 try:
294+ set_context_network_name(engine_name)
295295 init_fn(get_engine_from_settings(engine_name))
296296 except SearxEngineResponseException as exc:
297297 logger.warn('%s engine: Fail to initialize // %s', engine_name, exc)
searx/engines/duckduckgo.py+1−1
@@ -6,7 +6,7 @@
66from lxml.html import fromstring
77from json import loads
88from searx.utils import extract_text, match_language, eval_xpath, dict_subset
9−from searx.poolrequests import get
9+from searx.network import get
1010
1111# about
1212about = {
searx/engines/duckduckgo_images.py+1−1
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
88from searx.exceptions import SearxEngineAPIException
99from searx.engines.duckduckgo import get_region_code
1010from searx.engines.duckduckgo import _fetch_supported_languages, supported_languages_url # NOQA # pylint: disable=unused-import
11−from searx.poolrequests import get
11+from searx.network import get
1212
1313# about
1414about = {
searx/engines/gigablast.py+1−1
@@ -8,7 +8,7 @@ import re
88from json import loads
99from urllib.parse import urlencode
1010# from searx import logger
11−from searx.poolrequests import get
11+from searx.network import get
1212
1313# about
1414about = {
searx/engines/pubmed.py+1−1
@@ -7,7 +7,7 @@ from flask_babel import gettext
77from lxml import etree
88from datetime import datetime
99from urllib.parse import urlencode
10−from searx.poolrequests import get
10+from searx.network import get
1111
1212# about
1313about = {
searx/engines/qwant.py+1−1
@@ -8,7 +8,7 @@ from json import loads
88from urllib.parse import urlencode
99from searx.utils import html_to_text, match_language
1010from searx.exceptions import SearxEngineAPIException, SearxEngineCaptchaException
11−from searx.raise_for_httperror import raise_for_httperror
11+from searx.network import raise_for_httperror
1212
1313# about
1414about = {
searx/engines/seznam.py+1−1
@@ -5,7 +5,7 @@
55
66from urllib.parse import urlencode
77from lxml import html
8−from searx.poolrequests import get
8+from searx.network import get
99from searx.exceptions import SearxEngineAccessDeniedException
1010from searx.utils import (
1111 extract_text,
searx/engines/soundcloud.py+1−1
@@ -9,7 +9,7 @@ from lxml import html
99from dateutil import parser
1010from urllib.parse import quote_plus, urlencode
1111from searx import logger
12−from searx.poolrequests import get as http_get
12+from searx.network import get as http_get
1313
1414# about
1515about = {
searx/engines/spotify.py+3−2
@@ -5,9 +5,10 @@
55
66from json import loads
77from urllib.parse import urlencode
8−import searx.poolrequests as requests
98import base64
109
10+from searx.network import post as http_post
11+
1112# about
1213about = {
1314 "website": 'https://www.spotify.com',
@@ -38,7 +39,7 @@ def request(query, params):
3839
3940 params['url'] = search_url.format(query=urlencode({'q': query}), offset=offset)
4041
41− r = requests.post(
42+ r = http_post(
4243 'https://accounts.spotify.com/api/token',
4344 data={'grant_type': 'client_credentials'},
4445 headers={'Authorization': 'Basic ' + base64.b64encode(
searx/engines/wikidata.py+1−1
@@ -12,7 +12,7 @@ from babel.dates import format_datetime, format_date, format_time, get_datetime_
1212
1313from searx import logger
1414from searx.data import WIKIDATA_UNITS
15−from searx.poolrequests import post, get
15+from searx.network import post, get
1616from searx.utils import match_language, searx_useragent, get_string_replaces_function
1717from searx.external_urls import get_external_url, get_earth_coordinates_url, area_to_osm_zoom
1818from searx.engines.wikipedia import _fetch_supported_languages, supported_languages_url # NOQA # pylint: disable=unused-import
searx/engines/wikipedia.py+1−1
@@ -7,7 +7,7 @@ from urllib.parse import quote
77from json import loads
88from lxml.html import fromstring
99from searx.utils import match_language, searx_useragent
10−from searx.raise_for_httperror import raise_for_httperror
10+from searx.network import raise_for_httperror
1111
1212# about
1313about = {
searx/engines/wolframalpha_noapi.py+1−1
@@ -7,7 +7,7 @@ from json import loads
77from time import time
88from urllib.parse import urlencode
99
10−from searx.poolrequests import get as http_get
10+from searx.network import get as http_get
1111
1212# about
1313about = {
searx/engines/wordnik.py+1−1
@@ -6,7 +6,7 @@
66from lxml.html import fromstring
77from searx import logger
88from searx.utils import extract_text
9−from searx.raise_for_httperror import raise_for_httperror
9+from searx.network import raise_for_httperror
1010
1111logger = logger.getChild('Wordnik engine')
1212
searx/engines/yggtorrent.py+1−1
@@ -8,7 +8,7 @@ from operator import itemgetter
88from datetime import datetime
99from urllib.parse import quote
1010from searx.utils import extract_text, get_torrent_size
11−from searx.poolrequests import get as http_get
11+from searx.network import get as http_get
1212
1313# about
1414about = {
searx/network/__init__.pynew file+189
@@ -0,0 +1,189 @@
1+# SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+import asyncio
4+import threading
5+import concurrent.futures
6+from time import time
7+
8+import httpx
9+import h2.exceptions
10+
11+from .network import get_network, initialize
12+from .client import LOOP
13+from .raise_for_httperror import raise_for_httperror
14+
15+# queue.SimpleQueue: Support Python 3.6
16+try:
17+ from queue import SimpleQueue
18+except ImportError:
19+ from queue import Empty
20+ from collections import deque
21+
22+ class SimpleQueue:
23+ """Minimal backport of queue.SimpleQueue"""
24+
25+ def __init__(self):
26+ self._queue = deque()
27+ self._count = threading.Semaphore(0)
28+
29+ def put(self, item):
30+ self._queue.append(item)
31+ self._count.release()
32+
33+ def get(self):
34+ if not self._count.acquire(True):
35+ raise Empty
36+ return self._queue.popleft()
37+
38+
39+THREADLOCAL = threading.local()
40+
41+
42+def reset_time_for_thread():
43+ THREADLOCAL.total_time = 0
44+
45+
46+def get_time_for_thread():
47+ return THREADLOCAL.total_time
48+
49+
50+def set_timeout_for_thread(timeout, start_time=None):
51+ THREADLOCAL.timeout = timeout
52+ THREADLOCAL.start_time = start_time
53+
54+
55+def set_context_network_name(network_name):
56+ THREADLOCAL.network = get_network(network_name)
57+
58+
59+def get_context_network():
60+ try:
61+ return THREADLOCAL.network
62+ except AttributeError:
63+ return get_network()
64+
65+
66+def request(method, url, **kwargs):
67+ """same as requests/requests/api.py request(...)"""
68+ time_before_request = time()
69+
70+ # timeout (httpx)
71+ if 'timeout' in kwargs:
72+ timeout = kwargs['timeout']
73+ else:
74+ timeout = getattr(THREADLOCAL, 'timeout', None)
75+ if timeout is not None:
76+ kwargs['timeout'] = timeout
77+
78+ # 2 minutes timeout for the requests without timeout
79+ timeout = timeout or 120
80+
81+ # ajdust actual timeout
82+ timeout += 0.2 # overhead
83+ start_time = getattr(THREADLOCAL, 'start_time', time_before_request)
84+ if start_time:
85+ timeout -= time() - start_time
86+
87+ # raise_for_error
88+ check_for_httperror = True
89+ if 'raise_for_httperror' in kwargs:
90+ check_for_httperror = kwargs['raise_for_httperror']
91+ del kwargs['raise_for_httperror']
92+
93+ # requests compatibility
94+ if isinstance(url, bytes):
95+ url = url.decode()
96+
97+ # network
98+ network = get_context_network()
99+
100+ # do request
101+ future = asyncio.run_coroutine_threadsafe(network.request(method, url, **kwargs), LOOP)
102+ try:
103+ response = future.result(timeout)
104+ except concurrent.futures.TimeoutError as e:
105+ raise httpx.TimeoutException('Timeout', request=None) from e
106+
107+ # requests compatibility
108+ # see also https://www.python-httpx.org/compatibility/#checking-for-4xx5xx-responses
109+ response.ok = not response.is_error
110+
111+ # update total_time.
112+ # See get_time_for_thread() and reset_time_for_thread()
113+ if hasattr(THREADLOCAL, 'total_time'):
114+ time_after_request = time()
115+ THREADLOCAL.total_time += time_after_request - time_before_request
116+
117+ # raise an exception
118+ if check_for_httperror:
119+ raise_for_httperror(response)
120+
121+ return response
122+
123+
124+def get(url, **kwargs):
125+ kwargs.setdefault('allow_redirects', True)
126+ return request('get', url, **kwargs)
127+
128+
129+def options(url, **kwargs):
130+ kwargs.setdefault('allow_redirects', True)
131+ return request('options', url, **kwargs)
132+
133+
134+def head(url, **kwargs):
135+ kwargs.setdefault('allow_redirects', False)
136+ return request('head', url, **kwargs)
137+
138+
139+def post(url, data=None, **kwargs):
140+ return request('post', url, data=data, **kwargs)
141+
142+
143+def put(url, data=None, **kwargs):
144+ return request('put', url, data=data, **kwargs)
145+
146+
147+def patch(url, data=None, **kwargs):
148+ return request('patch', url, data=data, **kwargs)
149+
150+
151+def delete(url, **kwargs):
152+ return request('delete', url, **kwargs)
153+
154+
155+async def stream_chunk_to_queue(network, q, method, url, **kwargs):
156+ try:
157+ async with network.stream(method, url, **kwargs) as response:
158+ q.put(response)
159+ async for chunk in response.aiter_bytes(65536):
160+ if len(chunk) > 0:
161+ q.put(chunk)
162+ except (httpx.HTTPError, OSError, h2.exceptions.ProtocolError) as e:
163+ q.put(e)
164+ finally:
165+ q.put(None)
166+
167+
168+def stream(method, url, **kwargs):
169+ """Replace httpx.stream.
170+
171+ Usage:
172+ stream = poolrequests.stream(...)
173+ response = next(stream)
174+ for chunk in stream:
175+ ...
176+
177+ httpx.Client.stream requires to write the httpx.HTTPTransport version of the
178+ the httpx.AsyncHTTPTransport declared above.
179+ """
180+ q = SimpleQueue()
181+ future = asyncio.run_coroutine_threadsafe(stream_chunk_to_queue(get_network(), q, method, url, **kwargs),
182+ LOOP)
183+ chunk_or_exception = q.get()
184+ while chunk_or_exception is not None:
185+ if isinstance(chunk_or_exception, Exception):
186+ raise chunk_or_exception
187+ yield chunk_or_exception
188+ chunk_or_exception = q.get()
189+ return future.result()
searx/network/client.pynew file+214
@@ -0,0 +1,214 @@
1+# SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+import asyncio
4+import logging
5+import threading
6+
7+import httpcore
8+import httpx
9+from httpx_socks import AsyncProxyTransport
10+from python_socks import parse_proxy_url
11+import python_socks._errors
12+
13+from searx import logger
14+
15+# Optional uvloop (support Python 3.6)
16+try:
17+ import uvloop
18+except ImportError:
19+ pass
20+else:
21+ uvloop.install()
22+
23+
24+logger = logger.getChild('searx.http.client')
25+LOOP = None
26+TRANSPORT_KWARGS = {
27+ 'backend': 'asyncio',
28+ 'trust_env': False,
29+}
30+
31+
32+async def close_connections_for_url(connection_pool: httpcore.AsyncConnectionPool, url: httpcore._utils.URL):
33+ origin = httpcore._utils.url_to_origin(url)
34+ logger.debug('Drop connections for %r', origin)
35+ connections_to_close = connection_pool._connections_for_origin(origin)
36+ for connection in connections_to_close:
37+ await connection_pool._remove_from_pool(connection)
38+ try:
39+ await connection.aclose()
40+ except httpcore.NetworkError as e:
41+ logger.warning('Error closing an existing connection', exc_info=e)
42+
43+
44+class AsyncHTTPTransportNoHttp(httpcore.AsyncHTTPTransport):
45+ """Block HTTP request"""
46+
47+ async def arequest(self, method, url, headers=None, stream=None, ext=None):
48+ raise httpcore.UnsupportedProtocol("HTTP protocol is disabled")
49+
50+
51+class AsyncProxyTransportFixed(AsyncProxyTransport):
52+ """Fix httpx_socks.AsyncProxyTransport
53+
54+ Map python_socks exceptions to httpcore.ProxyError
55+
56+ Map socket.gaierror to httpcore.ConnectError
57+
58+ Note: keepalive_expiry is ignored, AsyncProxyTransport should call:
59+ * self._keepalive_sweep()
60+ * self._response_closed(self, connection)
61+
62+ Note: AsyncProxyTransport inherit from AsyncConnectionPool
63+
64+ Note: the API is going to change on httpx 0.18.0
65+ see https://github.com/encode/httpx/pull/1522
66+ """
67+
68+ async def arequest(self, method, url, headers=None, stream=None, ext=None):
69+ retry = 2
70+ while retry > 0:
71+ retry -= 1
72+ try:
73+ return await super().arequest(method, url, headers, stream, ext)
74+ except (python_socks._errors.ProxyConnectionError,
75+ python_socks._errors.ProxyTimeoutError,
76+ python_socks._errors.ProxyError) as e:
77+ raise httpcore.ProxyError(e)
78+ except OSError as e:
79+ # socket.gaierror when DNS resolution fails
80+ raise httpcore.NetworkError(e)
81+ except httpcore.RemoteProtocolError as e:
82+ # in case of httpcore.RemoteProtocolError: Server disconnected
83+ await close_connections_for_url(self, url)
84+ logger.warning('httpcore.RemoteProtocolError: retry', exc_info=e)
85+ # retry
86+ except (httpcore.NetworkError, httpcore.ProtocolError) as e:
87+ # httpcore.WriteError on HTTP/2 connection leaves a new opened stream
88+ # then each new request creates a new stream and raise the same WriteError
89+ await close_connections_for_url(self, url)
90+ raise e
91+
92+
93+class AsyncHTTPTransportFixed(httpx.AsyncHTTPTransport):
94+ """Fix httpx.AsyncHTTPTransport"""
95+
96+ async def arequest(self, method, url, headers=None, stream=None, ext=None):
97+ retry = 2
98+ while retry > 0:
99+ retry -= 1
100+ try:
101+ return await super().arequest(method, url, headers, stream, ext)
102+ except OSError as e:
103+ # socket.gaierror when DNS resolution fails
104+ raise httpcore.ConnectError(e)
105+ except httpcore.CloseError as e:
106+ # httpcore.CloseError: [Errno 104] Connection reset by peer
107+ # raised by _keepalive_sweep()
108+ # from https://github.com/encode/httpcore/blob/4b662b5c42378a61e54d673b4c949420102379f5/httpcore/_backends/asyncio.py#L198 # noqa
109+ await close_connections_for_url(self._pool, url)
110+ logger.warning('httpcore.CloseError: retry', exc_info=e)
111+ # retry
112+ except httpcore.RemoteProtocolError as e:
113+ # in case of httpcore.RemoteProtocolError: Server disconnected
114+ await close_connections_for_url(self._pool, url)
115+ logger.warning('httpcore.RemoteProtocolError: retry', exc_info=e)
116+ # retry
117+ except (httpcore.ProtocolError, httpcore.NetworkError) as e:
118+ await close_connections_for_url(self._pool, url)
119+ raise e
120+
121+
122+def get_transport_for_socks_proxy(verify, http2, local_address, proxy_url, limit, retries):
123+ global LOOP, TRANSPORT_KWARGS
124+ # support socks5h (requests compatibility):
125+ # https://requests.readthedocs.io/en/master/user/advanced/#socks
126+ # socks5:// hostname is resolved on client side
127+ # socks5h:// hostname is resolved on proxy side
128+ rdns = False
129+ socks5h = 'socks5h://'
130+ if proxy_url.startswith(socks5h):
131+ proxy_url = 'socks5://' + proxy_url[len(socks5h):]
132+ rdns = True
133+
134+ proxy_type, proxy_host, proxy_port, proxy_username, proxy_password = parse_proxy_url(proxy_url)
135+
136+ return AsyncProxyTransportFixed(proxy_type=proxy_type, proxy_host=proxy_host, proxy_port=proxy_port,
137+ username=proxy_username, password=proxy_password,
138+ rdns=rdns,
139+ loop=LOOP,
140+ verify=verify,
141+ http2=http2,
142+ local_address=local_address,
143+ max_connections=limit.max_connections,
144+ max_keepalive_connections=limit.max_keepalive_connections,
145+ keepalive_expiry=limit.keepalive_expiry,
146+ retries=retries,
147+ **TRANSPORT_KWARGS)
148+
149+
150+def get_transport(verify, http2, local_address, proxy_url, limit, retries):
151+ return AsyncHTTPTransportFixed(verify=verify,
152+ http2=http2,
153+ local_address=local_address,
154+ proxy=httpx._config.Proxy(proxy_url) if proxy_url else None,
155+ limits=limit,
156+ retries=retries,
157+ **TRANSPORT_KWARGS)
158+
159+
160+def iter_proxies(proxies):
161+ # https://www.python-httpx.org/compatibility/#proxy-keys
162+ if isinstance(proxies, str):
163+ yield 'all://', proxies
164+ elif isinstance(proxies, dict):
165+ for pattern, proxy_url in proxies.items():
166+ yield pattern, proxy_url
167+
168+
169+def new_client(enable_http, verify, enable_http2,
170+ max_connections, max_keepalive_connections, keepalive_expiry,
171+ proxies, local_address, retries, max_redirects):
172+ limit = httpx.Limits(max_connections=max_connections,
173+ max_keepalive_connections=max_keepalive_connections,
174+ keepalive_expiry=keepalive_expiry)
175+ # See https://www.python-httpx.org/advanced/#routing
176+ mounts = {}
177+ for pattern, proxy_url in iter_proxies(proxies):
178+ if not enable_http and (pattern == 'http' or pattern.startswith('http://')):
179+ continue
180+ if proxy_url.startswith('socks4://') \
181+ or proxy_url.startswith('socks5://') \
182+ or proxy_url.startswith('socks5h://'):
183+ mounts[pattern] = get_transport_for_socks_proxy(verify, enable_http2, local_address, proxy_url, limit,
184+ retries)
185+ else:
186+ mounts[pattern] = get_transport(verify, enable_http2, local_address, proxy_url, limit, retries)
187+
188+ if not enable_http:
189+ mounts['http://'] = AsyncHTTPTransportNoHttp()
190+
191+ transport = get_transport(verify, enable_http2, local_address, None, limit, retries)
192+ return httpx.AsyncClient(transport=transport, mounts=mounts, max_redirects=max_redirects)
193+
194+
195+def init():
196+ # log
197+ for logger_name in ('hpack.hpack', 'hpack.table'):
198+ logging.getLogger(logger_name).setLevel(logging.WARNING)
199+
200+ # loop
201+ def loop_thread():
202+ global LOOP
203+ LOOP = asyncio.new_event_loop()
204+ LOOP.run_forever()
205+
206+ th = threading.Thread(
207+ target=loop_thread,
208+ name='asyncio_loop',
209+ daemon=True,
210+ )
211+ th.start()
212+
213+
214+init()
searx/network/network.pynew file+302
@@ -0,0 +1,302 @@
1+# SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+import atexit
4+import asyncio
5+import ipaddress
6+from itertools import cycle
7+
8+import httpx
9+
10+from .client import new_client, LOOP
11+
12+
13+DEFAULT_NAME = '__DEFAULT__'
14+NETWORKS = {}
15+# requests compatibility when reading proxy settings from settings.yml
16+PROXY_PATTERN_MAPPING = {
17+ 'http': 'http://',
18+ 'https': 'https://',
19+ 'socks4': 'socks4://',
20+ 'socks5': 'socks5://',
21+ 'socks5h': 'socks5h://',
22+ 'http:': 'http://',
23+ 'https:': 'https://',
24+ 'socks4:': 'socks4://',
25+ 'socks5:': 'socks5://',
26+ 'socks5h:': 'socks5h://',
27+}
28+
29+ADDRESS_MAPPING = {
30+ 'ipv4': '0.0.0.0',
31+ 'ipv6': '::'
32+}
33+
34+
35+class Network:
36+
37+ __slots__ = ('enable_http', 'verify', 'enable_http2',
38+ 'max_connections', 'max_keepalive_connections', 'keepalive_expiry',
39+ 'local_addresses', 'proxies', 'max_redirects', 'retries', 'retry_on_http_error',
40+ '_local_addresses_cycle', '_proxies_cycle', '_clients')
41+
42+ def __init__(self,
43+ enable_http=True,
44+ verify=True,
45+ enable_http2=False,
46+ max_connections=None,
47+ max_keepalive_connections=None,
48+ keepalive_expiry=None,
49+ proxies=None,
50+ local_addresses=None,
51+ retries=0,
52+ retry_on_http_error=None,
53+ max_redirects=30):
54+ self.enable_http = enable_http
55+ self.verify = verify
56+ self.enable_http2 = enable_http2
57+ self.max_connections = max_connections
58+ self.max_keepalive_connections = max_keepalive_connections
59+ self.keepalive_expiry = keepalive_expiry
60+ self.proxies = proxies
61+ self.local_addresses = local_addresses
62+ self.retries = retries
63+ self.retry_on_http_error = retry_on_http_error
64+ self.max_redirects = max_redirects
65+ self._local_addresses_cycle = self.get_ipaddress_cycle()
66+ self._proxies_cycle = self.get_proxy_cycles()
67+ self._clients = {}
68+ self.check_parameters()
69+
70+ def check_parameters(self):
71+ for address in self.iter_ipaddresses():
72+ if '/' in address:
73+ ipaddress.ip_network(address, False)
74+ else:
75+ ipaddress.ip_address(address)
76+
77+ if self.proxies is not None and not isinstance(self.proxies, (str, dict)):
78+ raise ValueError('proxies type has to be str, dict or None')
79+
80+ def iter_ipaddresses(self):
81+ local_addresses = self.local_addresses
82+ if not local_addresses:
83+ return
84+ elif isinstance(local_addresses, str):
85+ local_addresses = [local_addresses]
86+ for address in local_addresses:
87+ yield address
88+
89+ def get_ipaddress_cycle(self):
90+ while True:
91+ count = 0
92+ for address in self.iter_ipaddresses():
93+ if '/' in address:
94+ for a in ipaddress.ip_network(address, False).hosts():
95+ yield str(a)
96+ count += 1
97+ else:
98+ a = ipaddress.ip_address(address)
99+ yield str(a)
100+ count += 1
101+ if count == 0:
102+ yield None
103+
104+ def iter_proxies(self):
105+ if not self.proxies:
106+ return
107+ # https://www.python-httpx.org/compatibility/#proxy-keys
108+ if isinstance(self.proxies, str):
109+ yield 'all://', [self.proxies]
110+ else:
111+ for pattern, proxy_url in self.proxies.items():
112+ pattern = PROXY_PATTERN_MAPPING.get(pattern, pattern)
113+ if isinstance(proxy_url, str):
114+ proxy_url = [proxy_url]
115+ yield pattern, proxy_url
116+
117+ def get_proxy_cycles(self):
118+ proxy_settings = {}
119+ for pattern, proxy_urls in self.iter_proxies():
120+ proxy_settings[pattern] = cycle(proxy_urls)
121+ while True:
122+ yield tuple((pattern, next(proxy_url_cycle)) for pattern, proxy_url_cycle in proxy_settings.items())
123+
124+ def get_client(self, verify=None, max_redirects=None):
125+ verify = self.verify if verify is None else verify
126+ max_redirects = self.max_redirects if max_redirects is None else max_redirects
127+ local_address = next(self._local_addresses_cycle)
128+ proxies = next(self._proxies_cycle) # is a tuple so it can be part of the key
129+ key = (verify, max_redirects, local_address, proxies)
130+ if key not in self._clients or self._clients[key].is_closed:
131+ self._clients[key] = new_client(self.enable_http,
132+ verify,
133+ self.enable_http2,
134+ self.max_connections,
135+ self.max_keepalive_connections,
136+ self.keepalive_expiry,
137+ dict(proxies),
138+ local_address,
139+ 0,
140+ max_redirects)
141+ return self._clients[key]
142+
143+ async def aclose(self):
144+ async def close_client(client):
145+ try:
146+ await client.aclose()
147+ except httpx.HTTPError:
148+ pass
149+ await asyncio.gather(*[close_client(client) for client in self._clients.values()], return_exceptions=False)
150+
151+ @staticmethod
152+ def get_kwargs_clients(kwargs):
153+ kwargs_clients = {}
154+ if 'verify' in kwargs:
155+ kwargs_clients['verify'] = kwargs.pop('verify')
156+ if 'max_redirects' in kwargs:
157+ kwargs_clients['max_redirects'] = kwargs.pop('max_redirects')
158+ return kwargs_clients
159+
160+ def is_valid_respones(self, response):
161+ if (self.retry_on_http_error is True and 400 <= response.status_code <= 599) \
162+ or (isinstance(self.retry_on_http_error, list) and response.status_code in self.retry_on_http_error) \
163+ or (isinstance(self.retry_on_http_error, int) and response.status_code == self.retry_on_http_error):
164+ return False
165+ return True
166+
167+ async def request(self, method, url, **kwargs):
168+ retries = self.retries
169+ while retries >= 0: # pragma: no cover
170+ kwargs_clients = Network.get_kwargs_clients(kwargs)
171+ client = self.get_client(**kwargs_clients)
172+ try:
173+ response = await client.request(method, url, **kwargs)
174+ if self.is_valid_respones(response) or retries <= 0:
175+ return response
176+ except (httpx.RequestError, httpx.HTTPStatusError) as e:
177+ if retries <= 0:
178+ raise e
179+ retries -= 1
180+
181+ def stream(self, method, url, **kwargs):
182+ retries = self.retries
183+ while retries >= 0: # pragma: no cover
184+ kwargs_clients = Network.get_kwargs_clients(kwargs)
185+ client = self.get_client(**kwargs_clients)
186+ try:
187+ response = client.stream(method, url, **kwargs)
188+ if self.is_valid_respones(response) or retries <= 0:
189+ return response
190+ except (httpx.RequestError, httpx.HTTPStatusError) as e:
191+ if retries <= 0:
192+ raise e
193+ retries -= 1
194+
195+ @classmethod
196+ async def aclose_all(cls):
197+ await asyncio.gather(*[network.aclose() for network in NETWORKS.values()], return_exceptions=False)
198+
199+
200+def get_network(name=None):
201+ global NETWORKS
202+ return NETWORKS[name or DEFAULT_NAME]
203+
204+
205+def initialize(settings_engines=None, settings_outgoing=None):
206+ from searx.engines import engines
207+ from searx import settings
208+
209+ global NETWORKS
210+
211+ settings_engines = settings_engines or settings.get('engines')
212+ settings_outgoing = settings_outgoing or settings.get('outgoing')
213+
214+ # default parameters for AsyncHTTPTransport
215+ # see https://github.com/encode/httpx/blob/e05a5372eb6172287458b37447c30f650047e1b8/httpx/_transports/default.py#L108-L121 # noqa
216+ default_params = {
217+ 'enable_http': False,
218+ 'verify': True,
219+ 'enable_http2': settings_outgoing.get('enable_http2', True),
220+ # Magic number kept from previous code
221+ 'max_connections': settings_outgoing.get('pool_connections', 100),
222+ # Picked from constructor
223+ 'max_keepalive_connections': settings_outgoing.get('pool_maxsize', 10),
224+ #
225+ 'keepalive_expiry': settings_outgoing.get('keepalive_expiry', 5.0),
226+ 'local_addresses': settings_outgoing.get('source_ips'),
227+ 'proxies': settings_outgoing.get('proxies'),
228+ # default maximum redirect
229+ # from https://github.com/psf/requests/blob/8c211a96cdbe9fe320d63d9e1ae15c5c07e179f8/requests/models.py#L55
230+ 'max_redirects': settings_outgoing.get('max_redirects', 30),
231+ #
232+ 'retries': settings_outgoing.get('retries', 0),
233+ 'retry_on_http_error': None,
234+ }
235+
236+ def new_network(params):
237+ nonlocal default_params
238+ result = {}
239+ result.update(default_params)
240+ result.update(params)
241+ return Network(**result)
242+
243+ def iter_networks():
244+ nonlocal settings_engines
245+ for engine_spec in settings_engines:
246+ engine_name = engine_spec['name']
247+ engine = engines.get(engine_name)
248+ if engine is None:
249+ continue
250+ network = getattr(engine, 'network', None)
251+ yield engine_name, engine, network
252+
253+ if NETWORKS:
254+ done()
255+ NETWORKS.clear()
256+ NETWORKS[DEFAULT_NAME] = new_network({})
257+ NETWORKS['ipv4'] = new_network({'local_addresses': '0.0.0.0'})
258+ NETWORKS['ipv6'] = new_network({'local_addresses': '::'})
259+
260+ # define networks from outgoing.networks
261+ for network_name, network in settings_outgoing.get('networks', {}).items():
262+ NETWORKS[network_name] = new_network(network)
263+
264+ # define networks from engines.[i].network (except references)
265+ for engine_name, engine, network in iter_networks():
266+ if network is None:
267+ network = {}
268+ for attribute_name, attribute_value in default_params.items():
269+ if hasattr(engine, attribute_name):
270+ network[attribute_name] = getattr(engine, attribute_name)
271+ else:
272+ network[attribute_name] = attribute_value
273+ NETWORKS[engine_name] = new_network(network)
274+ elif isinstance(network, dict):
275+ NETWORKS[engine_name] = new_network(network)
276+
277+ # define networks from engines.[i].network (references)
278+ for engine_name, engine, network in iter_networks():
279+ if isinstance(network, str):
280+ NETWORKS[engine_name] = NETWORKS[network]
281+
282+
283+@atexit.register
284+def done():
285+ """Close all HTTP client
286+
287+ Avoid a warning at exit
288+ see https://github.com/encode/httpx/blob/1a6e254f72d9fd5694a1c10a28927e193ab4f76b/httpx/_client.py#L1785
289+
290+ Note: since Network.aclose has to be async, it is not possible to call this method on Network.__del__
291+ So Network.aclose is called here using atexit.register
292+ """
293+ try:
294+ if LOOP:
295+ future = asyncio.run_coroutine_threadsafe(Network.aclose_all(), LOOP)
296+ # wait 3 seconds to close the HTTP clients
297+ future.result(3)
298+ finally:
299+ NETWORKS.clear()
300+
301+
302+NETWORKS[DEFAULT_NAME] = Network()
searx/raise_for_httperror.py → searx/network/raise_for_httperror.pyrenamed

The content is the same.

searx/poolrequests.pydeleted−550
@@ -1,550 +0,0 @@
1−import atexit
2−import sys
3−import threading
4−import asyncio
5−import logging
6−import concurrent.futures
7−from time import time
8−from itertools import cycle
9−
10−import httpcore
11−import httpx
12−import h2.exceptions
13−from httpx_socks import AsyncProxyTransport
14−from python_socks import parse_proxy_url
15−import python_socks._errors
16−
17−from searx import settings
18−from searx import logger
19−from searx.raise_for_httperror import raise_for_httperror
20−
21−# Optional uvloop (support Python 3.6)
22−try:
23− import uvloop
24−except ImportError:
25− pass
26−else:
27− uvloop.install()
28−
29−# queue.SimpleQueue: Support Python 3.6
30−try:
31− from queue import SimpleQueue
32−except ImportError:
33− from queue import Empty
34− from collections import deque
35−
36− class SimpleQueue:
37− """Minimal backport of queue.SimpleQueue"""
38−
39− def __init__(self):
40− self._queue = deque()
41− self._count = threading.Semaphore(0)
42−
43− def put(self, item):
44− self._queue.append(item)
45− self._count.release()
46−
47− def get(self, timeout=None):
48− if not self._count.acquire(True, timeout):
49− raise Empty
50− return self._queue.popleft()
51−
52−
53−logger = logger.getChild('poolrequests')
54−
55−
56−try:
57− import ssl
58− if ssl.OPENSSL_VERSION_INFO[0:3] < (1, 0, 2):
59− # https://github.com/certifi/python-certifi#1024-bit-root-certificates
60− logger.critical('You are using an old openssl version({0}), please upgrade above 1.0.2!'
61− .format(ssl.OPENSSL_VERSION))
62− sys.exit(1)
63−except ImportError:
64− ssl = None
65−if not getattr(ssl, "HAS_SNI", False):
66− try:
67− import OpenSSL # pylint: disable=unused-import
68− except ImportError:
69− logger.critical("ssl doesn't support SNI and the pyopenssl module is not installed.\n"
70− "Some HTTPS connections will fail")
71− sys.exit(1)
72−
73−
74−LOOP = None
75−CLIENTS = dict()
76−THREADLOCAL = threading.local()
77−LIMITS = httpx.Limits(
78− # Magic number kept from previous code
79− max_connections=settings['outgoing'].get('pool_connections', 100),
80− # Picked from constructor
81− max_keepalive_connections=settings['outgoing'].get('pool_maxsize', 10),
82− #
83− keepalive_expiry=settings['outgoing'].get('keepalive_expiry', 5.0)
84−)
85−# default parameters for AsyncHTTPTransport
86−# see https://github.com/encode/httpx/blob/e05a5372eb6172287458b37447c30f650047e1b8/httpx/_transports/default.py#L108-L121 # noqa
87−TRANSPORT_KWARGS = {
88− 'http2': settings['outgoing'].get('http2', False),
89− 'retries': 0,
90− 'trust_env': False,
91− 'backend': 'asyncio'
92−}
93−# requests compatibility when reading proxy settings from settings.yml
94−PROXY_PATTERN_MAPPING = {
95− 'http': 'https://',
96− 'https:': 'https://'
97−}
98−# default maximum redirect
99−# from https://github.com/psf/requests/blob/8c211a96cdbe9fe320d63d9e1ae15c5c07e179f8/requests/models.py#L55
100−DEFAULT_REDIRECT_LIMIT = 30
101−
102−
103−if settings['outgoing'].get('source_ips'):
104− LOCAL_ADDRESS_CYCLE = cycle(settings['outgoing'].get('source_ips'))
105−else:
106− LOCAL_ADDRESS_CYCLE = cycle((None, ))
107−
108−
109−def set_timeout_for_thread(timeout, start_time=None):
110− THREADLOCAL.timeout = timeout
111− THREADLOCAL.start_time = start_time
112−
113−
114−def set_enable_http_protocol(enable_http):
115− THREADLOCAL.enable_http = enable_http
116−
117−
118−def get_enable_http_protocol():
119− try:
120− return THREADLOCAL.enable_http
121− except AttributeError:
122− return False
123−
124−
125−def reset_time_for_thread():
126− THREADLOCAL.total_time = 0
127−
128−
129−def get_time_for_thread():
130− return THREADLOCAL.total_time
131−
132−
133−def get_proxy_cycles(proxy_settings):
134− if not proxy_settings:
135− return None
136− # Backwards compatibility for single proxy in settings.yml
137− for protocol, proxy in proxy_settings.items():
138− if isinstance(proxy, str):
139− proxy_settings[protocol] = [proxy]
140−
141− for protocol in proxy_settings:
142− proxy_settings[protocol] = cycle(proxy_settings[protocol])
143− return proxy_settings
144−
145−
146−GLOBAL_PROXY_CYCLES = get_proxy_cycles(settings['outgoing'].get('proxies'))
147−
148−
149−def get_proxies(proxy_cycles):
150− if proxy_cycles:
151− return {protocol: next(proxy_cycle) for protocol, proxy_cycle in proxy_cycles.items()}
152− return None
153−
154−
155−def get_global_proxies():
156− return get_proxies(GLOBAL_PROXY_CYCLES)
157−
158−
159−async def close_connections_for_url(connection_pool: httpcore.AsyncConnectionPool, url: httpcore._utils.URL):
160− origin = httpcore._utils.url_to_origin(url)
161− logger.debug('Drop connections for %r', origin)
162− connections_to_close = connection_pool._connections_for_origin(origin)
163− for connection in connections_to_close:
164− await connection_pool._remove_from_pool(connection)
165− try:
166− await connection.aclose()
167− except httpcore.NetworkError as e:
168− logger.warning('Error closing an existing connection', exc_info=e)
169−
170−
171−class AsyncHTTPTransportNoHttp(httpcore.AsyncHTTPTransport):
172− """Block HTTP request"""
173−
174− async def arequest(self, method, url, headers=None, stream=None, ext=None):
175− raise httpcore.UnsupportedProtocol("HTTP protocol is disabled")
176−
177−
178−class AsyncProxyTransportFixed(AsyncProxyTransport):
179− """Fix httpx_socks.AsyncProxyTransport
180−
181− Map python_socks exceptions to httpcore.ProxyError
182−
183− Map socket.gaierror to httpcore.ConnectError
184−
185− Note: keepalive_expiry is ignored, AsyncProxyTransport should call:
186− * self._keepalive_sweep()
187− * self._response_closed(self, connection)
188−
189− Note: AsyncProxyTransport inherit from AsyncConnectionPool
190−
191− Note: the API is going to change on httpx 0.18.0
192− see https://github.com/encode/httpx/pull/1522
193− """
194−
195− async def arequest(self, method, url, headers=None, stream=None, ext=None):
196− retry = 2
197− while retry > 0:
198− retry -= 1
199− try:
200− return await super().arequest(method, url, headers, stream, ext)
201− except (python_socks._errors.ProxyConnectionError,
202− python_socks._errors.ProxyTimeoutError,
203− python_socks._errors.ProxyError) as e:
204− raise httpcore.ProxyError(e)
205− except OSError as e:
206− # socket.gaierror when DNS resolution fails
207− raise httpcore.NetworkError(e)
208− except httpcore.RemoteProtocolError as e:
209− # in case of httpcore.RemoteProtocolError: Server disconnected
210− await close_connections_for_url(self, url)
211− logger.warning('httpcore.RemoteProtocolError: retry', exc_info=e)
212− # retry
213− except (httpcore.NetworkError, httpcore.ProtocolError) as e:
214− # httpcore.WriteError on HTTP/2 connection leaves a new opened stream
215− # then each new request creates a new stream and raise the same WriteError
216− await close_connections_for_url(self, url)
217− raise e
218−
219−
220−class AsyncHTTPTransportFixed(httpx.AsyncHTTPTransport):
221− """Fix httpx.AsyncHTTPTransport"""
222−
223− async def arequest(self, method, url, headers=None, stream=None, ext=None):
224− retry = 2
225− while retry > 0:
226− retry -= 1
227− try:
228− return await super().arequest(method, url, headers, stream, ext)
229− except OSError as e:
230− # socket.gaierror when DNS resolution fails
231− raise httpcore.ConnectError(e)
232− except httpcore.CloseError as e:
233− # httpcore.CloseError: [Errno 104] Connection reset by peer
234− # raised by _keepalive_sweep()
235− # from https://github.com/encode/httpcore/blob/4b662b5c42378a61e54d673b4c949420102379f5/httpcore/_backends/asyncio.py#L198 # noqa
236− await close_connections_for_url(self._pool, url)
237− logger.warning('httpcore.CloseError: retry', exc_info=e)
238− # retry
239− except httpcore.RemoteProtocolError as e:
240− # in case of httpcore.RemoteProtocolError: Server disconnected
241− await close_connections_for_url(self._pool, url)
242− logger.warning('httpcore.RemoteProtocolError: retry', exc_info=e)
243− # retry
244− except (httpcore.ProtocolError, httpcore.NetworkError) as e:
245− await close_connections_for_url(self._pool, url)
246− raise e
247−
248−
249−def get_transport_for_socks_proxy(verify, local_address, proxy_url):
250− global LOOP, LIMITS, TRANSPORT_KWARGS
251− # support socks5h (requests compatibility):
252− # https://requests.readthedocs.io/en/master/user/advanced/#socks
253− # socks5:// hostname is resolved on client side
254− # socks5h:// hostname is resolved on proxy side
255− rdns = False
256− socks5h = 'socks5h://'
257− if proxy_url.startswith(socks5h):
258− proxy_url = 'socks5://' + proxy_url[len(socks5h):]
259− rdns = True
260−
261− proxy_type, proxy_host, proxy_port, proxy_username, proxy_password = parse_proxy_url(proxy_url)
262−
263− return AsyncProxyTransportFixed(proxy_type=proxy_type, proxy_host=proxy_host, proxy_port=proxy_port,
264− username=proxy_username, password=proxy_password,
265− rdns=rdns,
266− loop=LOOP,
267− verify=verify,
268− local_address=local_address,
269− max_connections=LIMITS.max_connections,
270− max_keepalive_connections=LIMITS.max_keepalive_connections,
271− keepalive_expiry=LIMITS.keepalive_expiry,
272− **TRANSPORT_KWARGS)
273−
274−
275−def get_transport(verify, local_address, proxy_url):
276− global LIMITS
277− return AsyncHTTPTransportFixed(verify=verify,
278− local_address=local_address,
279− limits=LIMITS,
280− proxy=httpx._config.Proxy(proxy_url) if proxy_url else None,
281− **TRANSPORT_KWARGS)
282−
283−
284−def iter_proxies(proxies):
285− # https://www.python-httpx.org/compatibility/#proxy-keys
286− if isinstance(proxies, str):
287− yield 'all://', proxies
288− elif isinstance(proxies, dict):
289− for pattern, proxy_url in proxies.items():
290− pattern = PROXY_PATTERN_MAPPING.get(pattern, pattern)
291− yield pattern, proxy_url
292−
293−
294−def new_client(verify, local_address, proxies, max_redirects, enable_http):
295− # See https://www.python-httpx.org/advanced/#routing
296− mounts = {}
297− for pattern, proxy_url in iter_proxies(proxies):
298− if not enable_http and (pattern == 'http' or pattern.startswith('http://')):
299− continue
300− if proxy_url.startswith('socks4://') \
301− or proxy_url.startswith('socks5://') \
302− or proxy_url.startswith('socks5h://'):
303− mounts[pattern] = get_transport_for_socks_proxy(verify, local_address, proxy_url)
304− else:
305− mounts[pattern] = get_transport(verify, local_address, proxy_url)
306−
307− if not enable_http:
308− mounts['http://'] = AsyncHTTPTransportNoHttp()
309−
310− transport = get_transport(verify, local_address, None)
311− return httpx.AsyncClient(transport=transport, mounts=mounts, max_redirects=max_redirects)
312−
313−
314−def get_client(verify, local_address, proxies, max_redirects, allow_http):
315− global CLIENTS
316− key = (verify, local_address, repr(proxies), max_redirects, allow_http)
317− if key not in CLIENTS:
318− CLIENTS[key] = new_client(verify, local_address, proxies, max_redirects, allow_http)
319− return CLIENTS[key]
320−
321−
322−async def send_request(method, url, enable_http, kwargs):
323− if isinstance(url, bytes):
324− url = url.decode()
325−
326− verify = kwargs.pop('verify', True)
327− local_address = next(LOCAL_ADDRESS_CYCLE)
328− proxies = kwargs.pop('proxies', None) or get_global_proxies()
329− max_redirects = kwargs.pop('max_redirects', DEFAULT_REDIRECT_LIMIT)
330−
331− client = get_client(verify, local_address, proxies, max_redirects, enable_http)
332− response = await client.request(method.upper(), url, **kwargs)
333−
334− # requests compatibility
335− # see also https://www.python-httpx.org/compatibility/#checking-for-4xx5xx-responses
336− response.ok = not response.is_error
337−
338− return response
339−
340−
341−def request(method, url, **kwargs):
342− """same as requests/requests/api.py request(...)"""
343− time_before_request = time()
344−
345− # timeout
346− if 'timeout' in kwargs:
347− timeout = kwargs['timeout']
348− else:
349− timeout = getattr(THREADLOCAL, 'timeout', None)
350− if timeout is not None:
351− kwargs['timeout'] = timeout
352−
353− # raise_for_error
354− check_for_httperror = True
355− if 'raise_for_httperror' in kwargs:
356− check_for_httperror = kwargs['raise_for_httperror']
357− del kwargs['raise_for_httperror']
358−
359− # do request
360− future = asyncio.run_coroutine_threadsafe(send_request(method, url, get_enable_http_protocol(), kwargs), LOOP)
361− try:
362− if timeout:
363− timeout += 0.2 # overhead
364− start_time = getattr(THREADLOCAL, 'start_time', time_before_request)
365− if start_time:
366− timeout -= time() - start_time
367−
368− response = future.result(timeout or 120)
369− except concurrent.futures.TimeoutError as e:
370− raise httpx.TimeoutException('Timeout', request=None) from e
371−
372− # update total_time.
373− # See get_time_for_thread() and reset_time_for_thread()
374− if hasattr(THREADLOCAL, 'total_time'):
375− time_after_request = time()
376− THREADLOCAL.total_time += time_after_request - time_before_request
377−
378− # raise an exception
379− if check_for_httperror:
380− raise_for_httperror(response)
381−
382− return response
383−
384−
385−async def stream_chunk_to_queue(method, url, q, **kwargs):
386− verify = kwargs.pop('verify', True)
387− local_address = next(LOCAL_ADDRESS_CYCLE)
388− proxies = kwargs.pop('proxies', None) or get_global_proxies()
389− # "30" from requests:
390− # https://github.com/psf/requests/blob/8c211a96cdbe9fe320d63d9e1ae15c5c07e179f8/requests/models.py#L55
391− max_redirects = kwargs.pop('max_redirects', 30)
392− client = get_client(verify, local_address, proxies, max_redirects, True)
393− try:
394− async with client.stream(method, url, **kwargs) as response:
395− q.put(response)
396− async for chunk in response.aiter_bytes(65536):
397− if len(chunk) > 0:
398− q.put(chunk)
399− except (httpx.HTTPError, OSError, h2.exceptions.ProtocolError) as e:
400− q.put(e)
401− finally:
402− q.put(None)
403−
404−
405−def stream(method, url, **kwargs):
406− """Replace httpx.stream.
407−
408− Usage:
409− stream = poolrequests.stream(...)
410− response = next(stream)
411− for chunk in stream:
412− ...
413−
414− httpx.Client.stream requires to write the httpx.HTTPTransport version of the
415− the httpx.AsyncHTTPTransport declared above.
416− """
417− q = SimpleQueue()
418− future = asyncio.run_coroutine_threadsafe(stream_chunk_to_queue(method, url, q, **kwargs), LOOP)
419− chunk_or_exception = q.get(timeout=60)
420− while chunk_or_exception is not None:
421− if isinstance(chunk_or_exception, Exception):
422− raise chunk_or_exception
423− yield chunk_or_exception
424− chunk_or_exception = q.get(timeout=60)
425− return future.result()
426−
427−
428−def get(url, **kwargs):
429− kwargs.setdefault('allow_redirects', True)
430− return request('get', url, **kwargs)
431−
432−
433−def options(url, **kwargs):
434− kwargs.setdefault('allow_redirects', True)
435− return request('options', url, **kwargs)
436−
437−
438−def head(url, **kwargs):
439− kwargs.setdefault('allow_redirects', False)
440− return request('head', url, **kwargs)
441−
442−
443−def post(url, data=None, **kwargs):
444− return request('post', url, data=data, **kwargs)
445−
446−
447−def put(url, data=None, **kwargs):
448− return request('put', url, data=data, **kwargs)
449−
450−
451−def patch(url, data=None, **kwargs):
452− return request('patch', url, data=data, **kwargs)
453−
454−
455−def delete(url, **kwargs):
456− return request('delete', url, **kwargs)
457−
458−
459−def init():
460− # log
461− for logger_name in ('hpack.hpack', 'hpack.table'):
462− logging.getLogger(logger_name).setLevel(logging.WARNING)
463−
464− # loop
465− def loop_thread():
466− global LOOP
467− LOOP = asyncio.new_event_loop()
468− LOOP.run_forever()
469−
470− th = threading.Thread(
471− target=loop_thread,
472− name='asyncio_loop',
473− daemon=True,
474− )
475− th.start()
476−
477−
478−@atexit.register
479−def done():
480− """Close all HTTP client
481−
482− Avoid a warning at exit
483− see https://github.com/encode/httpx/blob/1a6e254f72d9fd5694a1c10a28927e193ab4f76b/httpx/_client.py#L1785
484− """
485− global LOOP
486−
487− async def close_client(client):
488− try:
489− await client.aclose()
490− except httpx.HTTPError:
491− pass
492−
493− async def close_clients():
494− await asyncio.gather(*[close_client(client) for client in CLIENTS.values()], return_exceptions=False)
495− future = asyncio.run_coroutine_threadsafe(close_clients(), LOOP)
496− # wait 3 seconds to close the HTTP clients
497− future.result(3)
498−
499−
500−init()
501−
502−
503−# ## TEMPORARY DEBUG ##
504−
505−
506−def debug_connection(connection):
507− now = LOOP.time()
508− expired = (connection.state == httpcore._async.base.ConnectionState.IDLE
509− and connection.expires_at is not None
510− and now >= connection.expires_at)
511− return connection.info()\
512− + (', connect_failed' if connection.connect_failed else '')\
513− + (', expired' if expired else '')
514−
515−
516−def debug_origin(origin):
517− return origin[0].decode() + '://' + origin[1].decode() + ':' + str(origin[2])
518−
519−
520−def debug_transport(transport):
521− result = {
522− '__class__': str(transport.__class__.__name__)
523− }
524− if isinstance(transport, (httpx.AsyncHTTPTransport, AsyncHTTPTransportFixed)):
525− pool = transport._pool
526− result['__pool_class__'] = str(pool.__class__.__name__)
527− if isinstance(pool, httpcore.AsyncConnectionPool):
528− for origin, connections in pool._connections.items():
529− result[debug_origin(origin)] = [debug_connection(connection) for connection in connections]
530− return result
531− elif isinstance(transport, AsyncProxyTransportFixed):
532− for origin, connections in transport._connections.items():
533− result[debug_origin(origin)] = [debug_connection(connection) for connection in connections]
534− return result
535− return result
536−
537−
538−def debug_asyncclient(client, key=None):
539− result = {}
540− if key:
541− result['__key__'] = [k if isinstance(k, (str, int, float, bool, type(None))) else repr(k) for k in key]
542− result['__default__'] = debug_transport(client._transport)
543− for urlpattern, transport in client._mounts.items():
544− result[urlpattern.pattern] = debug_transport(transport)
545− return result
546−
547−
548−def debug_asyncclients():
549− global CLIENTS
550− return [debug_asyncclient(client, key) for key, client in CLIENTS.items()]
searx/search/checker/impl.py+3−3
@@ -13,7 +13,7 @@ from langdetect import detect_langs
1313from langdetect.lang_detect_exception import LangDetectException
1414import httpx
1515
16−from searx import poolrequests, logger
16+from searx import network, logger
1717from searx.results import ResultContainer
1818from searx.search.models import SearchQuery, EngineRef
1919from searx.search.processors import EngineProcessor
@@ -75,8 +75,8 @@ def _is_url_image(image_url):
7575 while retry > 0:
7676 a = time()
7777 try:
78− poolrequests.set_timeout_for_thread(10.0, time())
79− r = poolrequests.get(image_url, timeout=10.0, allow_redirects=True, headers={
78+ network.set_timeout_for_thread(10.0, time())
79+ r = network.get(image_url, timeout=10.0, allow_redirects=True, headers={
8080 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0',
8181 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
8282 'Accept-Language': 'en-US;q=0.5,en;q=0.3',
searx/search/processors/online.py+9−13
@@ -6,7 +6,7 @@ import asyncio
66
77import httpx
88
9−import searx.poolrequests as poolrequests
9+import searx.network
1010from searx.engines import settings
1111from searx import logger
1212from searx.utils import gen_useragent
@@ -64,10 +64,6 @@ class OnlineProcessor(EngineProcessor):
6464 auth=params['auth']
6565 )
6666
67− # setting engine based proxies
68− if hasattr(self.engine, 'proxies'):
69− request_args['proxies'] = poolrequests.get_proxies(self.engine.proxies)
70−
7167 # max_redirects
7268 max_redirects = params.get('max_redirects')
7369 if max_redirects:
@@ -85,9 +81,9 @@ class OnlineProcessor(EngineProcessor):
8581
8682 # specific type of request (GET or POST)
8783 if params['method'] == 'GET':
88− req = poolrequests.get
84+ req = searx.network.get
8985 else:
90− req = poolrequests.post
86+ req = searx.network.post
9187
9288 request_args['data'] = params['data']
9389
@@ -128,11 +124,11 @@ class OnlineProcessor(EngineProcessor):
128124
129125 def search(self, query, params, result_container, start_time, timeout_limit):
130126 # set timeout for all HTTP requests
131− poolrequests.set_timeout_for_thread(timeout_limit, start_time=start_time)
127+ searx.network.set_timeout_for_thread(timeout_limit, start_time=start_time)
132128 # reset the HTTP total time
133− poolrequests.reset_time_for_thread()
134− # enable HTTP only if explicitly enabled
135− poolrequests.set_enable_http_protocol(self.engine.enable_http)
129+ searx.network.reset_time_for_thread()
130+ # set the network
131+ searx.network.set_context_network_name(self.engine_name)
136132
137133 # suppose everything will be alright
138134 http_exception = False
@@ -149,7 +145,7 @@ class OnlineProcessor(EngineProcessor):
149145
150146 # update engine time when there is no exception
151147 engine_time = time() - start_time
152− page_load_time = poolrequests.get_time_for_thread()
148+ page_load_time = searx.network.get_time_for_thread()
153149 result_container.add_timing(self.engine_name, engine_time, page_load_time)
154150 with threading.RLock():
155151 self.engine.stats['engine_time'] += engine_time
@@ -162,7 +158,7 @@ class OnlineProcessor(EngineProcessor):
162158
163159 # Timing
164160 engine_time = time() - start_time
165− page_load_time = poolrequests.get_time_for_thread()
161+ page_load_time = searx.network.get_time_for_thread()
166162 result_container.add_timing(self.engine_name, engine_time, page_load_time)
167163
168164 # Record the errors
searx/settings.yml+5−6
@@ -72,16 +72,12 @@ outgoing: # communication with search engines
7272 useragent_suffix : "" # suffix of searx_useragent, could contain informations like an email address to the administrator
7373 pool_connections : 100 # The maximum number of concurrent connections that may be established.
7474 pool_maxsize : 20 # Allow the connection pool to maintain keep-alive connections below this point.
75− keepalive_expiry: 30.0 # Number of seconds to keep a connection in the pool
76− http2: True # Enable HTTP/2 (experimental)
75+ enable_http2: True # See https://www.python-httpx.org/http2/
7776# uncomment below section if you want to use a proxy
7877# see https://2.python-requests.org/en/latest/user/advanced/#proxies
7978# SOCKS proxies are also supported: see https://2.python-requests.org/en/latest/user/advanced/#socks
8079# proxies:
81−# http:
82−# - http://proxy1:8080
83−# - http://proxy2:8080
84−# https:
80+# all://:
8581# - http://proxy1:8080
8682# - http://proxy2:8080
8783# using_tor_proxy : True
@@ -91,6 +87,7 @@ outgoing: # communication with search engines
9187# source_ips:
9288# - 1.1.1.1
9389# - 1.1.1.2
90+# - fe80::/126
9491
9592# External plugin configuration
9693# See http://searx.github.io/searx/dev/plugins.html for more details
@@ -889,11 +886,13 @@ engines:
889886 engine : qwant
890887 shortcut : qwi
891888 categories : images
889+ network: qwant
892890
893891 - name : qwant news
894892 engine : qwant
895893 shortcut : qwn
896894 categories : news
895+ network: qwant
897896
898897# - name: library
899898# engine: recoll
searx/testing.py+2−2
@@ -10,8 +10,8 @@ import traceback
1010
1111from os.path import dirname, join, abspath, realpath
1212
13−from unittest import TestCase
1413from splinter import Browser
14+import aiounittest
1515
1616
1717class SearxTestLayer:
@@ -82,7 +82,7 @@ def run_robot_tests(tests):
8282 test(browser)
8383
8484
85−class SearxTestCase(TestCase):
85+class SearxTestCase(aiounittest.AsyncTestCase):
8686 """Base test case for non-robot tests."""
8787
8888 layer = SearxTestLayer
searx/webapp.py+2−7
@@ -93,7 +93,7 @@ from searx.plugins import plugins
9393from searx.plugins.oa_doi_rewrite import get_doi_resolver
9494from searx.preferences import Preferences, ValidationException, LANGUAGE_CODES
9595from searx.answerers import answerers
96−from searx import poolrequests
96+from searx.network import stream as http_stream
9797from searx.answerers import ask
9898from searx.metrology.error_recorder import errors_per_engines
9999
@@ -928,7 +928,7 @@ def image_proxy():
928928 try:
929929 headers = dict_subset(request.headers, {'If-Modified-Since', 'If-None-Match'})
930930 headers['User-Agent'] = gen_useragent()
931− stream = poolrequests.stream(
931+ stream = http_stream(
932932 method='GET',
933933 url=url,
934934 headers=headers,
@@ -1118,11 +1118,6 @@ def config():
11181118 })
11191119
11201120
1121−@app.route('/config/http')
1122−def config_http():
1123− return jsonify(poolrequests.debug_asyncclients())
1124−
1125−
11261121@app.errorhandler(404)
11271122def page_not_found(e):
11281123 return render('404.html'), 404
searx_extra/update/update_engine_descriptions.py+3−3
@@ -10,7 +10,7 @@ from searx.engines.wikidata import send_wikidata_query
1010from searx.utils import extract_text
1111import searx
1212import searx.search
13−import searx.poolrequests
13+import searx.network
1414
1515SPARQL_WIKIPEDIA_ARTICLE = """
1616SELECT DISTINCT ?item ?name
@@ -59,7 +59,7 @@ def get_wikipedia_summary(language, pageid):
5959 search_url = 'https://{language}.wikipedia.org/api/rest_v1/page/summary/{title}'
6060 url = search_url.format(title=quote(pageid), language=language)
6161 try:
62− response = searx.poolrequests.get(url)
62+ response = searx.network.get(url)
6363 response.raise_for_status()
6464 api_result = json.loads(response.text)
6565 return api_result.get('extract')
@@ -89,7 +89,7 @@ def get_website_description(url, lang1, lang2=None):
8989 lang_list.append(lang2)
9090 headers['Accept-Language'] = f'{",".join(lang_list)};q=0.8'
9191 try:
92− response = searx.poolrequests.get(url, headers=headers, timeout=10)
92+ response = searx.network.get(url, headers=headers, timeout=10)
9393 response.raise_for_status()
9494 except Exception:
9595 return (None, None)
tests/unit/network/__init__.pynew file

No changes to the content.

tests/unit/network/test_network.pynew file+236
@@ -0,0 +1,236 @@
1+# SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+from mock import patch
4+
5+import httpx
6+
7+from searx.network.network import Network, NETWORKS
8+from searx.testing import SearxTestCase
9+
10+
11+class TestNetwork(SearxTestCase):
12+
13+ def test_simple(self):
14+ network = Network()
15+
16+ self.assertEqual(next(network._local_addresses_cycle), None)
17+ self.assertEqual(next(network._proxies_cycle), ())
18+
19+ def test_ipaddress_cycle(self):
20+ network = NETWORKS['ipv6']
21+ self.assertEqual(next(network._local_addresses_cycle), '::')
22+ self.assertEqual(next(network._local_addresses_cycle), '::')
23+
24+ network = NETWORKS['ipv4']
25+ self.assertEqual(next(network._local_addresses_cycle), '0.0.0.0')
26+ self.assertEqual(next(network._local_addresses_cycle), '0.0.0.0')
27+
28+ network = Network(local_addresses=['192.168.0.1', '192.168.0.2'])
29+ self.assertEqual(next(network._local_addresses_cycle), '192.168.0.1')
30+ self.assertEqual(next(network._local_addresses_cycle), '192.168.0.2')
31+ self.assertEqual(next(network._local_addresses_cycle), '192.168.0.1')
32+
33+ network = Network(local_addresses=['192.168.0.0/30'])
34+ self.assertEqual(next(network._local_addresses_cycle), '192.168.0.1')
35+ self.assertEqual(next(network._local_addresses_cycle), '192.168.0.2')
36+ self.assertEqual(next(network._local_addresses_cycle), '192.168.0.1')
37+ self.assertEqual(next(network._local_addresses_cycle), '192.168.0.2')
38+
39+ network = Network(local_addresses=['fe80::/10'])
40+ self.assertEqual(next(network._local_addresses_cycle), 'fe80::1')
41+ self.assertEqual(next(network._local_addresses_cycle), 'fe80::2')
42+ self.assertEqual(next(network._local_addresses_cycle), 'fe80::3')
43+
44+ with self.assertRaises(ValueError):
45+ Network(local_addresses=['not_an_ip_address'])
46+
47+ def test_proxy_cycles(self):
48+ network = Network(proxies='http://localhost:1337')
49+ self.assertEqual(next(network._proxies_cycle), (('all://', 'http://localhost:1337'),))
50+
51+ network = Network(proxies={
52+ 'https': 'http://localhost:1337',
53+ 'http': 'http://localhost:1338'
54+ })
55+ self.assertEqual(next(network._proxies_cycle),
56+ (('https://', 'http://localhost:1337'), ('http://', 'http://localhost:1338')))
57+ self.assertEqual(next(network._proxies_cycle),
58+ (('https://', 'http://localhost:1337'), ('http://', 'http://localhost:1338')))
59+
60+ network = Network(proxies={
61+ 'https': ['http://localhost:1337', 'http://localhost:1339'],
62+ 'http': 'http://localhost:1338'
63+ })
64+ self.assertEqual(next(network._proxies_cycle),
65+ (('https://', 'http://localhost:1337'), ('http://', 'http://localhost:1338')))
66+ self.assertEqual(next(network._proxies_cycle),
67+ (('https://', 'http://localhost:1339'), ('http://', 'http://localhost:1338')))
68+
69+ with self.assertRaises(ValueError):
70+ Network(proxies=1)
71+
72+ def test_get_kwargs_clients(self):
73+ kwargs = {
74+ 'verify': True,
75+ 'max_redirects': 5,
76+ 'timeout': 2,
77+ }
78+ kwargs_client = Network.get_kwargs_clients(kwargs)
79+
80+ self.assertEqual(len(kwargs_client), 2)
81+ self.assertEqual(len(kwargs), 1)
82+
83+ self.assertEqual(kwargs['timeout'], 2)
84+
85+ self.assertTrue(kwargs_client['verify'])
86+ self.assertEqual(kwargs_client['max_redirects'], 5)
87+
88+ async def test_get_client(self):
89+ network = Network(verify=True)
90+ client1 = network.get_client()
91+ client2 = network.get_client(verify=True)
92+ client3 = network.get_client(max_redirects=10)
93+ client4 = network.get_client(verify=True)
94+ client5 = network.get_client(verify=False)
95+ client6 = network.get_client(max_redirects=10)
96+
97+ self.assertEqual(client1, client2)
98+ self.assertEqual(client1, client4)
99+ self.assertNotEqual(client1, client3)
100+ self.assertNotEqual(client1, client5)
101+ self.assertEqual(client3, client6)
102+
103+ await network.aclose()
104+
105+ async def test_aclose(self):
106+ network = Network(verify=True)
107+ network.get_client()
108+ await network.aclose()
109+
110+ async def test_request(self):
111+ a_text = 'Lorem Ipsum'
112+ response = httpx.Response(status_code=200, text=a_text)
113+ with patch.object(httpx.AsyncClient, 'request', return_value=response):
114+ network = Network(enable_http=True)
115+ response = await network.request('GET', 'https://example.com/')
116+ self.assertEqual(response.text, a_text)
117+ await network.aclose()
118+
119+
120+class TestNetworkRequestRetries(SearxTestCase):
121+
122+ TEXT = 'Lorem Ipsum'
123+
124+ @classmethod
125+ def get_response_404_then_200(cls):
126+ first = True
127+
128+ async def get_response(*args, **kwargs):
129+ nonlocal first
130+ if first:
131+ first = False
132+ return httpx.Response(status_code=403, text=TestNetworkRequestRetries.TEXT)
133+ return httpx.Response(status_code=200, text=TestNetworkRequestRetries.TEXT)
134+ return get_response
135+
136+ async def test_retries_ok(self):
137+ with patch.object(httpx.AsyncClient, 'request', new=TestNetworkRequestRetries.get_response_404_then_200()):
138+ network = Network(enable_http=True, retries=1, retry_on_http_error=403)
139+ response = await network.request('GET', 'https://example.com/')
140+ self.assertEqual(response.text, TestNetworkRequestRetries.TEXT)
141+ await network.aclose()
142+
143+ async def test_retries_fail_int(self):
144+ with patch.object(httpx.AsyncClient, 'request', new=TestNetworkRequestRetries.get_response_404_then_200()):
145+ network = Network(enable_http=True, retries=0, retry_on_http_error=403)
146+ response = await network.request('GET', 'https://example.com/')
147+ self.assertEqual(response.status_code, 403)
148+ await network.aclose()
149+
150+ async def test_retries_fail_list(self):
151+ with patch.object(httpx.AsyncClient, 'request', new=TestNetworkRequestRetries.get_response_404_then_200()):
152+ network = Network(enable_http=True, retries=0, retry_on_http_error=[403, 429])
153+ response = await network.request('GET', 'https://example.com/')
154+ self.assertEqual(response.status_code, 403)
155+ await network.aclose()
156+
157+ async def test_retries_fail_bool(self):
158+ with patch.object(httpx.AsyncClient, 'request', new=TestNetworkRequestRetries.get_response_404_then_200()):
159+ network = Network(enable_http=True, retries=0, retry_on_http_error=True)
160+ response = await network.request('GET', 'https://example.com/')
161+ self.assertEqual(response.status_code, 403)
162+ await network.aclose()
163+
164+ async def test_retries_exception_then_200(self):
165+ request_count = 0
166+
167+ async def get_response(*args, **kwargs):
168+ nonlocal request_count
169+ request_count += 1
170+ if request_count < 3:
171+ raise httpx.RequestError('fake exception', request=None)
172+ return httpx.Response(status_code=200, text=TestNetworkRequestRetries.TEXT)
173+
174+ with patch.object(httpx.AsyncClient, 'request', new=get_response):
175+ network = Network(enable_http=True, retries=2)
176+ response = await network.request('GET', 'https://example.com/')
177+ self.assertEqual(response.status_code, 200)
178+ self.assertEqual(response.text, TestNetworkRequestRetries.TEXT)
179+ await network.aclose()
180+
181+ async def test_retries_exception(self):
182+ async def get_response(*args, **kwargs):
183+ raise httpx.RequestError('fake exception', request=None)
184+
185+ with patch.object(httpx.AsyncClient, 'request', new=get_response):
186+ network = Network(enable_http=True, retries=0)
187+ with self.assertRaises(httpx.RequestError):
188+ await network.request('GET', 'https://example.com/')
189+ await network.aclose()
190+
191+
192+class TestNetworkStreamRetries(SearxTestCase):
193+
194+ TEXT = 'Lorem Ipsum'
195+
196+ @classmethod
197+ def get_response_exception_then_200(cls):
198+ first = True
199+
200+ def stream(*args, **kwargs):
201+ nonlocal first
202+ if first:
203+ first = False
204+ raise httpx.RequestError('fake exception', request=None)
205+ return httpx.Response(status_code=200, text=TestNetworkStreamRetries.TEXT)
206+ return stream
207+
208+ async def test_retries_ok(self):
209+ with patch.object(httpx.AsyncClient, 'stream', new=TestNetworkStreamRetries.get_response_exception_then_200()):
210+ network = Network(enable_http=True, retries=1, retry_on_http_error=403)
211+ response = network.stream('GET', 'https://example.com/')
212+ self.assertEqual(response.text, TestNetworkStreamRetries.TEXT)
213+ await network.aclose()
214+
215+ async def test_retries_fail(self):
216+ with patch.object(httpx.AsyncClient, 'stream', new=TestNetworkStreamRetries.get_response_exception_then_200()):
217+ network = Network(enable_http=True, retries=0, retry_on_http_error=403)
218+ with self.assertRaises(httpx.RequestError):
219+ network.stream('GET', 'https://example.com/')
220+ await network.aclose()
221+
222+ async def test_retries_exception(self):
223+ first = True
224+
225+ def stream(*args, **kwargs):
226+ nonlocal first
227+ if first:
228+ first = False
229+ return httpx.Response(status_code=403, text=TestNetworkRequestRetries.TEXT)
230+ return httpx.Response(status_code=200, text=TestNetworkRequestRetries.TEXT)
231+
232+ with patch.object(httpx.AsyncClient, 'stream', new=stream):
233+ network = Network(enable_http=True, retries=0, retry_on_http_error=403)
234+ response = network.stream('GET', 'https://example.com/')
235+ self.assertEqual(response.status_code, 403)
236+ await network.aclose()
tests/unit/test_poolrequests.pydeleted−62
@@ -1,62 +0,0 @@
1−from searx.testing import SearxTestCase
2−
3−from searx.poolrequests import get_proxy_cycles, get_proxies
4−
5−
6−CONFIG = {'http': ['http://localhost:9090', 'http://localhost:9092'],
7− 'https': ['http://localhost:9091', 'http://localhost:9093']}
8−
9−
10−class TestProxy(SearxTestCase):
11−
12− def test_noconfig(self):
13− cycles = get_proxy_cycles(None)
14− self.assertIsNone(cycles)
15−
16− cycles = get_proxy_cycles(False)
17− self.assertIsNone(cycles)
18−
19− def test_oldconfig(self):
20− config = {
21− 'http': 'http://localhost:9090',
22− 'https': 'http://localhost:9091',
23− }
24− cycles = get_proxy_cycles(config)
25− self.assertEqual(next(cycles['http']), 'http://localhost:9090')
26− self.assertEqual(next(cycles['http']), 'http://localhost:9090')
27− self.assertEqual(next(cycles['https']), 'http://localhost:9091')
28− self.assertEqual(next(cycles['https']), 'http://localhost:9091')
29−
30− def test_one_proxy(self):
31− config = {
32− 'http': ['http://localhost:9090'],
33− 'https': ['http://localhost:9091'],
34− }
35− cycles = get_proxy_cycles(config)
36− self.assertEqual(next(cycles['http']), 'http://localhost:9090')
37− self.assertEqual(next(cycles['http']), 'http://localhost:9090')
38− self.assertEqual(next(cycles['https']), 'http://localhost:9091')
39− self.assertEqual(next(cycles['https']), 'http://localhost:9091')
40−
41− def test_multiple_proxies(self):
42− cycles = get_proxy_cycles(CONFIG)
43− self.assertEqual(next(cycles['http']), 'http://localhost:9090')
44− self.assertEqual(next(cycles['http']), 'http://localhost:9092')
45− self.assertEqual(next(cycles['http']), 'http://localhost:9090')
46− self.assertEqual(next(cycles['https']), 'http://localhost:9091')
47− self.assertEqual(next(cycles['https']), 'http://localhost:9093')
48− self.assertEqual(next(cycles['https']), 'http://localhost:9091')
49−
50− def test_getproxies_none(self):
51− self.assertIsNone(get_proxies(None))
52−
53− def test_getproxies_config(self):
54− cycles = get_proxy_cycles(CONFIG)
55− self.assertEqual(get_proxies(cycles), {
56− 'http': 'http://localhost:9090',
57− 'https': 'http://localhost:9091'
58− })
59− self.assertEqual(get_proxies(cycles), {
60− 'http': 'http://localhost:9092',
61− 'https': 'http://localhost:9093'
62− })