CircuitPython clients can't complete requests to HTTPServer
anecdata opened this issue · 10 comments
Trying to use adafruit_requests
to connect either the 5100S-based WIZnet Pico EVB running Adafruit CircuitPython 7.2.5 on 2022-04-06; Raspberry Pi Pico with rp2040
or the Adafruit Feather RP2040 with Adafruit Ethernet FeatherWing running Adafruit CircuitPython 7.2.5 on 2022-04-06; Adafruit Feather RP2040 with rp2040
to an Adafruit Feather ESP32-S2 TFT running HTTPServer
on an IPv4 (this repo simpletest example; no mDNS/hostname/FQDN involved) results in one of the following exception traces in all cases:
Either (less often):
Traceback (most recent call last):
File "code.py", line 211, in <module>
File "adafruit_requests.py", line 815, in get
File "adafruit_requests.py", line 685, in request
OutOfRetries: Repeated socket failures
Or (more often):
Traceback (most recent call last):
File "code.py", line 211, in <module>
File "adafruit_requests.py", line 815, in get
File "adafruit_requests.py", line 661, in request
File "adafruit_requests.py", line 529, in _get_socket
File "adafruit_wiznet5k/adafruit_wiznet5k_socket.py", line 251, in connect
File "adafruit_wiznet5k/adafruit_wiznet5k.py", line 574, in socket_connect
RuntimeError: Failed to establish connection.
I was initially going to file this issue in WIZnet, but a sanity check of trying to connect to the HTTPServer
from ESP32-S2 (e.g., Adafruit Feather ESP32-S2 TFT) also gets an exception every time, after about a minute. That surprised me, I may be doing something wrong. Maybe it's a Requests issue.
ESP32-S2 Client Code:
import traceback
import wifi
import socketpool
import ssl
import adafruit_requests
from adafruit_httpserver import HTTPServer, HTTPResponse
from secrets import secrets
wifi.radio.connect(secrets['ssid'], secrets['password'])
pool = socketpool.SocketPool(wifi.radio)
requests = adafruit_requests.Session(pool, ssl.create_default_context())
URLS = [
"http://wifitest.adafruit.com/testwifi/index.html",
"http://192.168.5.32", # LAN Apache server
"http://192.168.6.164", # LAN ESP32-S2 with adafruit_httpserver
]
for url in URLS:
try:
print(url)
with requests.get(url) as response:
print(response.status_code, response.reason)
except Exception as ex:
traceback.print_exception(ex, ex, ex.__traceback__)
Output:
code.py output:
http://wifitest.adafruit.com/testwifi/index.html
200 bytearray(b'OK')
http://192.168.5.32
200 bytearray(b'OK')
http://192.168.6.164
Traceback (most recent call last):
File "code.py", line 22, in <module>
File "adafruit_requests.py", line 720, in get
File "adafruit_requests.py", line 661, in request
File "adafruit_requests.py", line 512, in _get_socket
RuntimeError: Sending request failed
Both Espressif client and Espressif server are running:
Adafruit CircuitPython 7.2.5 on 2022-04-06; Adafruit Feather ESP32-S2 TFT with ESP32S2
Connecting to the HTTPServer
from a browser or curl
works fine.
Connecting to local Apache server at an IPv4 from any of these clients works fine.
Similar behavior with ESP32SPI (Adafruit CircuitPython 7.2.0 on 2022-02-24; Adafruit PyPortal with samd51j20
):
import time
import board
import busio
import traceback
from digitalio import DigitalInOut
import adafruit_requests as requests
import adafruit_esp32spi.adafruit_esp32spi_socket as socket
from adafruit_esp32spi import adafruit_esp32spi
from secrets import secrets
URLS = [
"http://wifitest.adafruit.com/testwifi/index.html",
"http://192.168.5.32", # LAN Apache server
"http://192.168.6.164", # LAN ESP32-S2 with adafruit_httpserver
]
esp32_cs = DigitalInOut(board.ESP_CS)
esp32_ready = DigitalInOut(board.ESP_BUSY)
esp32_reset = DigitalInOut(board.ESP_RESET)
spi = board.SPI()
esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
requests.set_socket(socket, esp)
print("Firmware vers.", esp.firmware_version)
esp.connect_AP(secrets["ssid"], secrets["password"])
print(f"ping 192.168.6.164: {esp.ping("192.168.6.164")}ms")
for url in URLS:
try:
print(url)
with requests.get(url) as response:
print(response.status_code, response.reason)
# print(response.headers)
# print(response.content)
except Exception as ex:
traceback.print_exception(ex, ex, ex.__traceback__)
Output:
code.py output:
Firmware vers. bytearray(b'1.7.4\x00')
ping 192.168.6.164: 120ms
ping 192.168.6.164: 240ms
ping 192.168.6.164: 240ms
http://wifitest.adafruit.com/testwifi/index.html
200 bytearray(b'OK')
http://192.168.5.32
200 bytearray(b'OK')
http://192.168.6.164
Traceback (most recent call last):
File "code.py", line 30, in <module>
File "adafruit_requests.py", line 815, in get
File "adafruit_requests.py", line 685, in request
OutOfRetries: Repeated socket failures
It's looking like Requests. I thought maybe a fringe DNS or reachability issue, but ping
seems to work. I updated all clients to addafruit_requests
1.11.2 and retested with same results.
Went back and ping
-tested on Espressif port pinging from an ESP32-S2 CircuitPython client. I'm not 100% confident of ping
on Espressif port in general, but pinging the LAN Apache server every 5 seconds yields a reasonable number virtually every time. Pinging the HTTPServer
IPv4 address gives a reasonable number about 25% of the time, and None
the rest of the time. So it is possible there's some subtle reachability issue, but I have yet to see any successful HTTP GET requests to the HTTPServer
IPv4 address from a CircuitPython device, where I would expect it to be at worst intermittent like ping
. I keep double-checking with curl
and browser and they're both finding the HTTPServer
pretty reliably.
update: It is looking to be related to adafruit_requests
(however, also seems to be something unique about adafruit_httpserver
in the mix)... I set up a TCP socket client to fudge an HTTP request directly and it gets the full result from the HTTPServer
IPv4 address most of the time.
Code:
import supervisor
import wifi
import socketpool
import ssl
import ipaddress
from secrets import secrets
HOST = "192.168.6.164"
PATH = "/"
PORT = 80
TIMEOUT = 15
MAXBUF = 4096
print("Connecting to wifi")
wifi.radio.connect(secrets["ssid"], secrets["password"])
pool = socketpool.SocketPool(wifi.radio)
print("Self IP", wifi.radio.ipv4_address)
server_ipv4 = ipaddress.ip_address(pool.getaddrinfo(HOST, PORT)[0][4][0])
print("Server ping", server_ipv4, wifi.radio.ping(server_ipv4), "ms")
print("Create TCP Client Socket")
s = pool.socket(pool.AF_INET, pool.SOCK_STREAM)
s.settimeout(TIMEOUT)
print("Connecting")
s.connect((HOST, PORT))
size = s.send(f"GET {PATH} HTTP/1.1\r\nHost: {HOST}:{PORT}\r\n\r\n".encode())
print("Sent", size, "bytes")
# just get the first hunk and call it a day
buf = bytearray(MAXBUF)
size = s.recv_into(buf)
print('Received', size, "bytes", buf[:size])
s.close()
print("Done-ish.")
supervisor.reload()
Example result:
code.py output:
Connecting to wifi
Self IP 192.168.6.161
Server ping 192.168.6.164 0.054 ms
Create TCP Client Socket
Connecting
Sent 42 bytes
Received 111 bytes bytearray(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 27\r\nConnection: close\r\n\r\n2022-04-17T23:31:25-05:00\r\n')
Done-ish.
Sorry this is all so horrendously long, it's been baffling. Maybe it's just me and there's something odd in my network environment.
Further test to try to rule out issues with my LAN...
• HTTPServer
on its own ESP32-S2 wifi AP
• Client ESP32-S2 connects as a Station to the HTTPServer
wifi AP
In the case of running a HTTP / TCP server on an ESP32-S2 wifi AP, the AP IP address (192.168.4.1) must be used for the bind()
(not sure why):
server.serve_forever("192.168.4.1")
...rather than something like: s.bind(("", 80))
which generally works in CircuitPython when the client device is a wifi Station, and in CPython (''
"is used to bind to all interfaces").
In this configuration the HTTP Request client gets the same result as above (RuntimeError: Sending request failed
). The TCP client with TCP echo server work fine. The TCP client gets mixed replies (ETIMEDOUT
, empty payload, or HTTP headers) from the HTTPServer
, but that variance could possibly be attributed to it not being the full HTTP client the HTTPServer
is expecting.
I think these AP results support leaning toward this being a Requests [+ HTTPServer
] issue and not a local network issue.
(rewrote this comment for clarity after more testing)
I think I have a clue to the issue. Requests chunks out the HTTP request into a bunch of tiny send
commands:
https://github.com/adafruit/Adafruit_CircuitPython_Requests/blob/2e6b3f9feeacc678402454f7d3416b04a9a93e17/adafruit_requests.py#L580
As-is, the HTTPServer
seems to get the GET
chunk, then moves on and it's a malformed request. From the browser and curl, I think the request comes in all at once, or at least the timing is such that the server gets it all in one read.
If I add in a dumb iteration of recvfrom_into in serve_forever, it gets the requests from the requests library in two distinct chunks:
bytearray(b'GET')
bytearray(b' / HTTP/1.1\r\nHost: 192.168.6.164\r\nUser-Agent: Adafruit CircuitPython\r\n\r\n')
So probably there should be some read retries and / or timeout mechanism, or have Requests send a single chunk, or read the header line by line (to accommodate future body handling variations based on headers)?
Didn't expect any difference, but re-tested with beta.3 clients so we didn't think this issue was stale.
Adafruit CircuitPython 8.0.0-beta.3 on 2022-10-20; Adafruit QT Py ESP32S2 with ESP32S2
Adafruit CircuitPython 8.0.0-beta.3 on 2022-10-20; Raspberry Pi Pico W with rp2040
Interestingly, the Pico W client almost always succeeds. But the ESP32-S2 client still fails (typ. OutOfRetries
) more often than not. Different timing on the Requests chunks I suspect.
Thanks for mentioning, after some testing, here are my conclusions.
- Python's
requests
, web browsers, curl etc. works with currentadafruit_httpserver
- As you mentioned above,
adafruit_requests
sends request by using_send
multiple times, which seems to be the problem, as it is the only client that is problematic, is seems logical to me that changing theadafruit_requests
module is the way to go.
One solution that works is accumulating all bytes in variable and then sending all at once with single _send
, like:
def _send_request(
...
):
# pylint: disable=too-many-arguments
request_bytes = bytes()
request_bytes += bytes(method, "utf-8")
request_bytes += b" /"
...
if data:
request_bytes += bytes(data)
self._send(socket, request_bytes)
Other thant that, it is worth noting that max size of buffer, at least on ESP32-S2 seems to be 2880 bytes, which can also be found in CircuitPython itself, so any data above that limit will be ignored, at least with current build of adafruit_httpserver
.
circuitpython\ports\espressif\esp-idf-config\sdkconfig.defaults:
854: CONFIG_TCP_MSL=60000
855: CONFIG_TCP_SND_BUF_DEFAULT=2880 <--
856: CONFIG_TCP_WND_DEFAULT=2880
I think adafruit_requests
is implemented that way to minimize memory footprint so that it will run better on memory-constrained devices. Would adafruit_httpserver
respecting a client Content-Length
header (along with a timeout) not work? It seems like as this server code matures, it will need to process different client headers in various ways.
The Content-Length
header only describes the length of the request's body, not including other headers and start line.
Additionally, its presence is not required, so depending on its value would be problematic.
But, timeout seemed like a possible solution. After some tweaking I managed to solve the problem with getting only partial data like bytearray(b'GET')
.
Please try the latest version of refactor-and-additional-features branch and let me know whether it fulfills this Issue.
For it to work properly it might be required to set server.socket_timeout
in code to value higher that default 0
e.g. 1
.
I can test this weekend.
I don't think we support chunked requests yet (?) so there should be a Content-length
header (HTTP/1.1). The start line and headers are easily distinguishable from the request body.
A user agent that sends a request that contains a message body MUST send either a valid Content-Length header field or use the chunked transfer coding.