Skip to content

Commit 333b586

Browse files
authored
Merge pull request #50 from tannewt/better_error_handling
Better handle errors by retrying
2 parents 8b24815 + 7f2877e commit 333b586

File tree

6 files changed

+289
-44
lines changed

6 files changed

+289
-44
lines changed

adafruit_requests.py

100755100644
Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
__version__ = "0.0.0-auto.0"
5454
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Requests.git"
5555

56+
import errno
57+
5658

5759
class _RawResponse:
5860
def __init__(self, response):
@@ -73,6 +75,10 @@ def readinto(self, buf):
7375
return self._response._readinto(buf) # pylint: disable=protected-access
7476

7577

78+
class _SendFailed(Exception):
79+
"""Custom exception to abort sending a request."""
80+
81+
7682
class Response:
7783
"""The response from a request, contains all the headers/content"""
7884

@@ -94,11 +100,13 @@ def __init__(self, sock, session=None):
94100
self._chunked = False
95101

96102
self._backwards_compatible = not hasattr(sock, "recv_into")
97-
if self._backwards_compatible:
98-
print("Socket missing recv_into. Using more memory to be compatible")
99103

100104
http = self._readto(b" ")
101105
if not http:
106+
if session:
107+
session._close_socket(self.socket)
108+
else:
109+
self.socket.close()
102110
raise RuntimeError("Unable to read HTTP response.")
103111
self.status_code = int(bytes(self._readto(b" ")))
104112
self.reason = self._readto(b"\r\n")
@@ -414,30 +422,41 @@ def _get_socket(self, host, port, proto, *, timeout=1):
414422
addr_info = self._socket_pool.getaddrinfo(
415423
host, port, 0, self._socket_pool.SOCK_STREAM
416424
)[0]
417-
sock = self._socket_pool.socket(addr_info[0], addr_info[1], addr_info[2])
418-
connect_host = addr_info[-1][0]
419-
if proto == "https:":
420-
sock = self._ssl_context.wrap_socket(sock, server_hostname=host)
421-
connect_host = host
422-
sock.settimeout(timeout) # socket read timeout
423-
ok = True
424-
try:
425-
ok = sock.connect((connect_host, port))
426-
except MemoryError:
427-
if not any(self._socket_free.items()):
428-
raise
429-
ok = False
430-
431-
# We couldn't connect due to memory so clean up the open sockets.
432-
if not ok:
433-
self._free_sockets()
434-
# Recreate the socket because the ESP-IDF won't retry the connection if it failed once.
435-
sock = None # Clear first so the first socket can be cleaned up.
436-
sock = self._socket_pool.socket(addr_info[0], addr_info[1], addr_info[2])
425+
retry_count = 0
426+
sock = None
427+
while retry_count < 5 and sock is None:
428+
if retry_count > 0:
429+
if any(self._socket_free.items()):
430+
self._free_sockets()
431+
else:
432+
raise RuntimeError("Sending request failed")
433+
retry_count += 1
434+
435+
try:
436+
sock = self._socket_pool.socket(
437+
addr_info[0], addr_info[1], addr_info[2]
438+
)
439+
except OSError:
440+
continue
441+
442+
connect_host = addr_info[-1][0]
437443
if proto == "https:":
438444
sock = self._ssl_context.wrap_socket(sock, server_hostname=host)
445+
connect_host = host
439446
sock.settimeout(timeout) # socket read timeout
440-
sock.connect((connect_host, port))
447+
448+
try:
449+
sock.connect((connect_host, port))
450+
except MemoryError:
451+
sock.close()
452+
sock = None
453+
except OSError:
454+
sock.close()
455+
sock = None
456+
457+
if sock is None:
458+
raise RuntimeError("Repeated socket failures")
459+
441460
self._open_sockets[key] = sock
442461
self._socket_free[sock] = False
443462
return sock
@@ -446,11 +465,15 @@ def _get_socket(self, host, port, proto, *, timeout=1):
446465
def _send(socket, data):
447466
total_sent = 0
448467
while total_sent < len(data):
449-
sent = socket.send(data[total_sent:])
468+
# ESP32SPI sockets raise a RuntimeError when unable to send.
469+
try:
470+
sent = socket.send(data[total_sent:])
471+
except RuntimeError:
472+
sent = 0
450473
if sent is None:
451474
sent = len(data)
452475
if sent == 0:
453-
raise RuntimeError("Connection closed")
476+
raise _SendFailed()
454477
total_sent += sent
455478

456479
def _send_request(self, socket, host, method, path, headers, data, json):
@@ -532,12 +555,19 @@ def request(
532555
self._last_response.close()
533556
self._last_response = None
534557

535-
socket = self._get_socket(host, port, proto, timeout=timeout)
536-
try:
537-
self._send_request(socket, host, method, path, headers, data, json)
538-
except:
539-
self._close_socket(socket)
540-
raise
558+
# We may fail to send the request if the socket we got is closed already. So, try a second
559+
# time in that case.
560+
retry_count = 0
561+
while retry_count < 2:
562+
retry_count += 1
563+
socket = self._get_socket(host, port, proto, timeout=timeout)
564+
try:
565+
self._send_request(socket, host, method, path, headers, data, json)
566+
break
567+
except _SendFailed:
568+
self._close_socket(socket)
569+
if retry_count > 1:
570+
raise
541571

542572
resp = Response(socket, self) # our response
543573
if "location" in resp.headers and 300 <= resp.status_code <= 399:
@@ -588,10 +618,9 @@ def __init__(self, socket, tls_mode):
588618
def connect(self, address):
589619
"""connect wrapper to add non-standard mode parameter"""
590620
try:
591-
self._socket.connect(address, self._mode)
592-
return True
593-
except RuntimeError:
594-
return False
621+
return self._socket.connect(address, self._mode)
622+
except RuntimeError as error:
623+
raise OSError(errno.ENOMEM) from error
595624

596625

597626
class _FakeSSLContext:

tests/concurrent_test.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from unittest import mock
2+
import mocket
3+
import pytest
4+
import errno
5+
import adafruit_requests
6+
7+
ip = "1.2.3.4"
8+
host = "wifitest.adafruit.com"
9+
host2 = "wifitest2.adafruit.com"
10+
path = "/testwifi/index.html"
11+
text = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)"
12+
response = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + text
13+
14+
15+
def test_second_connect_fails_memoryerror():
16+
pool = mocket.MocketPool()
17+
pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),)
18+
sock = mocket.Mocket(response)
19+
sock2 = mocket.Mocket(response)
20+
sock3 = mocket.Mocket(response)
21+
pool.socket.call_count = 0 # Reset call count
22+
pool.socket.side_effect = [sock, sock2, sock3]
23+
sock2.connect.side_effect = MemoryError()
24+
25+
ssl = mocket.SSLContext()
26+
27+
s = adafruit_requests.Session(pool, ssl)
28+
r = s.get("https://" + host + path)
29+
30+
sock.send.assert_has_calls(
31+
[mock.call(b"testwifi/index.html"),]
32+
)
33+
34+
sock.send.assert_has_calls(
35+
[mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"), mock.call(b"\r\n"),]
36+
)
37+
assert r.text == str(text, "utf-8")
38+
39+
host2 = "test.adafruit.com"
40+
s.get("https://" + host2 + path)
41+
42+
sock.connect.assert_called_once_with((host, 443))
43+
sock2.connect.assert_called_once_with((host2, 443))
44+
sock3.connect.assert_called_once_with((host2, 443))
45+
# Make sure that the socket is closed after send fails.
46+
sock.close.assert_called_once()
47+
sock2.close.assert_called_once()
48+
assert sock3.close.call_count == 0
49+
assert pool.socket.call_count == 3
50+
51+
52+
def test_second_connect_fails_oserror():
53+
pool = mocket.MocketPool()
54+
pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),)
55+
sock = mocket.Mocket(response)
56+
sock2 = mocket.Mocket(response)
57+
sock3 = mocket.Mocket(response)
58+
pool.socket.call_count = 0 # Reset call count
59+
pool.socket.side_effect = [sock, sock2, sock3]
60+
sock2.connect.side_effect = OSError(errno.ENOMEM)
61+
62+
ssl = mocket.SSLContext()
63+
64+
s = adafruit_requests.Session(pool, ssl)
65+
r = s.get("https://" + host + path)
66+
67+
sock.send.assert_has_calls(
68+
[mock.call(b"testwifi/index.html"),]
69+
)
70+
71+
sock.send.assert_has_calls(
72+
[mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"), mock.call(b"\r\n"),]
73+
)
74+
assert r.text == str(text, "utf-8")
75+
76+
host2 = "test.adafruit.com"
77+
s.get("https://" + host2 + path)
78+
79+
sock.connect.assert_called_once_with((host, 443))
80+
sock2.connect.assert_called_once_with((host2, 443))
81+
sock3.connect.assert_called_once_with((host2, 443))
82+
# Make sure that the socket is closed after send fails.
83+
sock.close.assert_called_once()
84+
sock2.close.assert_called_once()
85+
assert sock3.close.call_count == 0
86+
assert pool.socket.call_count == 3

tests/legacy_mocket.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ def __init__(self, response):
1616
self.send = mock.Mock(side_effect=self._send)
1717
self.readline = mock.Mock(side_effect=self._readline)
1818
self.recv = mock.Mock(side_effect=self._recv)
19+
self.fail_next_send = False
1920
self._response = response
2021
self._position = 0
2122

2223
def _send(self, data):
23-
return len(data)
24+
if self.fail_next_send:
25+
self.fail_next_send = False
26+
raise RuntimeError("Send failed")
27+
return None
2428

2529
def _readline(self):
2630
i = self._response.find(b"\r\n", self._position)
@@ -32,4 +36,5 @@ def _recv(self, count):
3236
end = self._position + count
3337
r = self._response[self._position : end]
3438
self._position = end
39+
print(r)
3540
return r

tests/legacy_test.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest import mock
22
import legacy_mocket as mocket
33
import json
4+
import pytest
45
import adafruit_requests
56

67
ip = "1.2.3.4"
@@ -49,3 +50,122 @@ def test_post_string():
4950
sock.connect.assert_called_once_with((ip, 80))
5051
sock.send.assert_called_with(b"31F")
5152
r.close()
53+
54+
55+
def test_second_tls_send_fails():
56+
mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),)
57+
sock = mocket.Mocket(headers + encoded)
58+
sock2 = mocket.Mocket(headers + encoded)
59+
mocket.socket.call_count = 0 # Reset call count
60+
mocket.socket.side_effect = [sock, sock2]
61+
62+
adafruit_requests.set_socket(mocket, mocket.interface)
63+
r = adafruit_requests.get("https://" + host + "/testwifi/index.html")
64+
65+
sock.send.assert_has_calls(
66+
[mock.call(b"testwifi/index.html"),]
67+
)
68+
69+
sock.send.assert_has_calls(
70+
[mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),]
71+
)
72+
assert r.text == str(encoded, "utf-8")
73+
74+
sock.fail_next_send = True
75+
adafruit_requests.get("https://" + host + "/get2")
76+
77+
sock.connect.assert_called_once_with((host, 443), mocket.interface.TLS_MODE)
78+
sock2.connect.assert_called_once_with((host, 443), mocket.interface.TLS_MODE)
79+
# Make sure that the socket is closed after send fails.
80+
sock.close.assert_called_once()
81+
assert sock2.close.call_count == 0
82+
assert mocket.socket.call_count == 2
83+
84+
85+
def test_second_send_fails():
86+
mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),)
87+
sock = mocket.Mocket(headers + encoded)
88+
sock2 = mocket.Mocket(headers + encoded)
89+
mocket.socket.call_count = 0 # Reset call count
90+
mocket.socket.side_effect = [sock, sock2]
91+
92+
adafruit_requests.set_socket(mocket, mocket.interface)
93+
r = adafruit_requests.get("http://" + host + "/testwifi/index.html")
94+
95+
sock.send.assert_has_calls(
96+
[mock.call(b"testwifi/index.html"),]
97+
)
98+
99+
sock.send.assert_has_calls(
100+
[mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),]
101+
)
102+
assert r.text == str(encoded, "utf-8")
103+
104+
sock.fail_next_send = True
105+
adafruit_requests.get("http://" + host + "/get2")
106+
107+
sock.connect.assert_called_once_with((ip, 80))
108+
sock2.connect.assert_called_once_with((ip, 80))
109+
# Make sure that the socket is closed after send fails.
110+
sock.close.assert_called_once()
111+
assert sock2.close.call_count == 0
112+
assert mocket.socket.call_count == 2
113+
114+
115+
def test_first_read_fails():
116+
mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),)
117+
sock = mocket.Mocket(b"")
118+
mocket.socket.call_count = 0 # Reset call count
119+
mocket.socket.side_effect = [sock]
120+
121+
adafruit_requests.set_socket(mocket, mocket.interface)
122+
123+
with pytest.raises(RuntimeError):
124+
r = adafruit_requests.get("http://" + host + "/testwifi/index.html")
125+
126+
sock.send.assert_has_calls(
127+
[mock.call(b"testwifi/index.html"),]
128+
)
129+
130+
sock.send.assert_has_calls(
131+
[mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),]
132+
)
133+
134+
sock.connect.assert_called_once_with((ip, 80))
135+
# Make sure that the socket is closed after the first receive fails.
136+
sock.close.assert_called_once()
137+
assert mocket.socket.call_count == 1
138+
139+
140+
def test_second_tls_connect_fails():
141+
mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),)
142+
sock = mocket.Mocket(headers + encoded)
143+
sock2 = mocket.Mocket(headers + encoded)
144+
sock3 = mocket.Mocket(headers + encoded)
145+
mocket.socket.call_count = 0 # Reset call count
146+
mocket.socket.side_effect = [sock, sock2, sock3]
147+
sock2.connect.side_effect = RuntimeError("error connecting")
148+
149+
adafruit_requests.set_socket(mocket, mocket.interface)
150+
r = adafruit_requests.get("https://" + host + "/testwifi/index.html")
151+
152+
sock.send.assert_has_calls(
153+
[mock.call(b"testwifi/index.html"),]
154+
)
155+
156+
sock.send.assert_has_calls(
157+
[mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),]
158+
)
159+
assert r.text == str(encoded, "utf-8")
160+
161+
host2 = "test.adafruit.com"
162+
r = adafruit_requests.get("https://" + host2 + "/get2")
163+
164+
sock.connect.assert_called_once_with((host, 443), mocket.interface.TLS_MODE)
165+
sock2.connect.assert_called_once_with((host2, 443), mocket.interface.TLS_MODE)
166+
sock3.connect.assert_called_once_with((host2, 443), mocket.interface.TLS_MODE)
167+
# Make sure that the socket is closed after send fails.
168+
sock.close.assert_called_once()
169+
sock2.close.assert_called_once()
170+
assert sock3.close.call_count == 0
171+
assert mocket.socket.call_count == 3

0 commit comments

Comments
 (0)