Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create ConnectionManager #1

Merged
merged 24 commits into from
Feb 20, 2024
Merged

Create ConnectionManager #1

merged 24 commits into from
Feb 20, 2024

Conversation

justmobilize
Copy link
Collaborator

@justmobilize justmobilize commented Dec 22, 2023

ConnectionManager

The goal of Adafruit_CircuitPython_ConnectionManager is to make using sockets easier and the pool (socketpool, adafruit_esp32spi_socket, etc) managed in one place.

A global ConnectionManager is created and so it doesn't matter in which file or where you create Sessions since they all use the same one.

To test, you will want the following branches:

Example code for using adafruit_requests on either a board with wifi or an airlift:

import os

import adafruit_requests

import adafruit_connection_manager

try:
    import wifi

    radio = wifi.radio
    onboard_wifi = True

except ImportError:
    import board
    import busio
    from adafruit_esp32spi import adafruit_esp32spi
    from digitalio import DigitalInOut

    spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
    esp32_cs = DigitalInOut(board.D13)
    esp32_ready = DigitalInOut(board.D11)
    esp32_reset = DigitalInOut(board.D12)
    radio = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
    onboard_wifi = False

wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID")
wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD")

print("Connecting to WiFi")
if onboard_wifi:
    while not radio.connected:
        radio.connect(wifi_ssid, wifi_password)
else:
    while not radio.is_connected:
        try:
            radio.connect_AP(wifi_ssid, wifi_password)
        except OSError as os_exc:
            print(f"could not connect to AP, retrying: {os_exc}")
            continue

pool = adafruit_connection_manager.get_radio_socketpool(radio)
ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio)
requests = adafruit_requests.Session(pool, ssl_context)

print("Making request")
url = "https://www.adafruit.com/api/quotes.php"
response = requests.get(url)
print(response.json())

Minimum code on a board with WiFi:

import os

import adafruit_requests
import wifi

import adafruit_connection_manager

wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID")
wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD")

radio = wifi.radio
while not radio.connected:
    radio.connect(wifi_ssid, wifi_password)

pool = adafruit_connection_manager.get_radio_socketpool(radio)
ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio)

requests = adafruit_requests.Session(pool, ssl_context)
print(requests.get("http://www.adafruit.com/api/quotes.php").json())

Not knowing how people like to overload methods, I would love to make adafruit_requests.Session like this:

class Session:
    def __init__(self, radio=None, socket_pool=None, ssl_context=None, session_id=None): 

And make sure either radio or socket_pool are passed in. if radio, call get_radio_socketpool and get_radio_ssl_context internally.

Thus have even less imports.

MQTT example:

import os
import time

import adafruit_minimqtt.adafruit_minimqtt as MQTT

import adafruit_connection_manager

try:
    import wifi

    radio = wifi.radio
    onboard_wifi = True

except ImportError:
    import board
    import busio
    from adafruit_esp32spi import adafruit_esp32spi
    from digitalio import DigitalInOut

    spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
    esp32_cs = DigitalInOut(board.D13)
    esp32_ready = DigitalInOut(board.D11)
    esp32_reset = DigitalInOut(board.D12)
    radio = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
    onboard_wifi = False

wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID")
wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD")
aio_username = os.getenv("AIO_USERNAME")
aio_key = os.getenv("AIO_KEY")

print("Connecting to WiFi")
if onboard_wifi:
    while not radio.connected:
        radio.connect(wifi_ssid, wifi_password)
else:
    while not radio.is_connected:
        try:
            radio.connect_AP(wifi_ssid, wifi_password)
        except OSError as os_exc:
            print(f"could not connect to AP, retrying: {os_exc}")
            continue

pool = adafruit_connection_manager.get_radio_socketpool(radio)

mqtt_client = MQTT.MQTT(
    broker="io.adafruit.com",
    username=aio_username,
    password=aio_key,
    socket_pool=pool,
)

mqtt_client.connect()
for i in range(10):
    print(i)
    mqtt_client.publish(f"{aio_username}/feeds/button", i)
    time.sleep(1)
mqtt_client.disconnect()

Example of using unique sessions (not all boards support this. Tested on ESP32S3 TFT):

import os

import adafruit_requests
import wifi

import adafruit_connection_manager

wifi_ssid = os.getenv("CIRCUITPY_WIFI_SSID")
wifi_password = os.getenv("CIRCUITPY_WIFI_PASSWORD")

radio = wifi.radio
while not radio.connected:
    radio.connect(wifi_ssid, wifi_password)

pool = adafruit_connection_manager.get_radio_socketpool(radio)
ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio)

sessions = {}
session_id = 0
while True:
  session_id += 1
  try:
    requests = adafruit_requests.Session(pool, ssl_context, session_id=str(session_id))
    response = requests.get("http://www.adafruit.com/api/quotes.php")
    sessions[session_id] = {
      "requests": requests,
      "response": response,
      "result": "",
      "more": True,
    }
    print(f"Started session {session_id}")
  except:
    break

chunk_size = 5
b = bytearray(chunk_size)
while True:
  more = False
  for session_id, data in sessions.items():
    if data["more"]:
      size = data["response"]._readinto(b)
      if size:
        more = True
        data["result"] += str(b[:size], "utf-8")
      else:
        data["more"] = False
        data["response"].close()
    print(f"{session_id}: {data["result"]}")
  if not more:
    break

Interesting Note: on the ESP32S3 TFT, running 8.2.9 I got 3 sessions before it raised an error. On 9.0.0-beta.0 I got 4
Testing on 9.0.0-beta.1 on a UM FeatherS3 I got 7 sessions.

# typing


if not sys.implementation.name == "circuitpython":
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block pulled from Adafruit_CircuitPython_Requests

Comment on lines 147 to 160
class SocketConnectMemoryError(OSError):
"""ConnectionManager Exception class for an MemoryError when connecting a socket."""


class SocketConnectOSError(OSError):
"""ConnectionManager Exception class for an OSError when connecting a socket."""


class SocketGetOSError(OSError):
"""ConnectionManager Exception class for an OSError when getting a socket."""


class SocketGetRuntimeError(RuntimeError):
"""ConnectionManager Exception class for an RuntimeError when getting a socket."""
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These added so Adafruit_CircuitPython_MiniMQTT can handle retries internally.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These seem weird to me. Shouldn't the outer code know the context already?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is from MiniMQTT._get_connect_socket where different exceptions are flagged as a TemporaryError so it knows if it's retriable or not

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are removed

# classes


class _FakeSSLSocket:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also pulled from Adafruit_CircuitPython_Requests

return _FakeSSLSocket(socket, self._iface.TLS_MODE)


def create_fake_ssl_context(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modeled after Adafruit_CircuitPython_Requests.set_socket(), this makes it so Sessions are easier to use with esp32spi

socket = self._socket_pool.socket(addr_info[0], addr_info[1])
except OSError as exc:
logger.debug(f" OSError getting socket: {exc}")
last_exc_new_type = SocketGetOSError
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These add support so MiniMQTT can handle retries

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does MiniMQTT need additional retries?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above. MiniMQTT has it's own built in retrying and some exceptions are retriable and others are fatal

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this code should be able to implement the same policy. I don't know why they error behavior would be specific to minimqtt.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is now removed

@justmobilize
Copy link
Collaborator Author

justmobilize commented Dec 29, 2023

For information:

Sizes before:

14976 adafruit_minimqtt.mpy
 8578 adafruit_requests.mpy
-----
23554

Sizes after:

13828 adafruit_minimqtt.mpy
 6117 adafruit_requests.mpy
-----
19945

Sizes total:

13828 adafruit_minimqtt.mpy
 6117 adafruit_requests.mpy
 4825 adafruit_connection_manager.mpy
-----
24770

Increase: 1216 bytes

Copy link
Member

@tannewt tannewt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for factoring this out! Just a couple questions.

Comment on lines 147 to 160
class SocketConnectMemoryError(OSError):
"""ConnectionManager Exception class for an MemoryError when connecting a socket."""


class SocketConnectOSError(OSError):
"""ConnectionManager Exception class for an OSError when connecting a socket."""


class SocketGetOSError(OSError):
"""ConnectionManager Exception class for an OSError when getting a socket."""


class SocketGetRuntimeError(RuntimeError):
"""ConnectionManager Exception class for an RuntimeError when getting a socket."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These seem weird to me. Shouldn't the outer code know the context already?



def get_connection_members(radio):
"""Helper to get needed connection mamers for common boards"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really a fan of this because members is vague. Could this be the ConnectionManager constructor instead and then have requests.Session et al recognize when they are given a connection manager?

Another clearer way would be to have two functions, get_pool() and get_ssl_context().

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can make this get_connection_pool_and_ssl_context. I had this idea of just passing the radio to things like requests and having it get the pool and ssl_context. The thought was around simplifying imports.
I can also take this out of here for now, and add this as a second add.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also allows you to:

requests = adafruit_requests.Session(*connection_members)

or even:

requests = adafruit_requests.Session(*get_connection_members(radio))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a huge fan of what it is hiding. I'd prefer it in a follow up PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did break these into get_radio_socketpool and get_radio_ssl_contexts. Still happy to remove if we don't want theses yet

socket = self._socket_pool.socket(addr_info[0], addr_info[1])
except OSError as exc:
logger.debug(f" OSError getting socket: {exc}")
last_exc_new_type = SocketGetOSError
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does MiniMQTT need additional retries?

justmobilize and others added 2 commits February 6, 2024 13:28
Co-authored-by: Scott Shawcroft <scott@tannewt.org>
Comment on lines 301 to 308
while retry_count < max_retries and socket is None:
if retry_count > 0:
logger.debug(f" retry #{retry_count}")
if any(self._socket_free.items()):
self._free_sockets()
else:
raise RuntimeError("Sending request failed") from last_exc
retry_count += 1
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tannewt I have a question on how you would like to fix this. In theory we try 5 times and if we fail we raise with Repeated socket failures, but that will never happen.

If it's the first request:
Try one: error
Try two: any(self._socket_free.items()) returns false, error right away

If it's not the first request:
Try one: error
Try two: any(self._socket_free.items()) returns true, clears _socket_free, error
Try three: any(self._socket_free.items()) returns false, error right away

The only case it would try 5 times is if you have an open and active socket, which seems an odd case to keep trying. Thoughts? Leave it as is for now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am writing tests and this made sense to change to:

if any(socket for socket, free in self._socket_free.items() if free is True)

to avoid extra loops but then couldn't get that last error at all

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, my brain doesn't have enough context to be concrete. It seems like the retrying here should be robust enough that minimqtt doesn't need to do any retrying itself.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so 2 separate issues.

  1. This code was pulled from requests and has built in retry, that's flawed
  2. minimqtt also built in a retry mechanism that is similar but different on what they feel is retry-able

It might be easiest to look at the minimqtt PR listed above to see what I mean.

I'm happy to work towards finding something that works for all libraries, but don't know how to get consensus

@justmobilize
Copy link
Collaborator Author

@tannewt and @brentru I opened this PR so I can simplify the retry logic here.

@justmobilize justmobilize requested a review from tannewt February 14, 2024 17:16
@justmobilize
Copy link
Collaborator Author

@tannewt simplified the retry logic and asked a question. After any back-and-forth I can start adding tests

Copy link
Member

@tannewt tannewt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry logic looks good to me too. Let me know when you are ready for a final review.

@justmobilize
Copy link
Collaborator Author

@tannewt final quick questions.

  1. On another PR you asked to have the for Adafruit removed since I'm not paid for it. But cookie-cutter put it in this. Should I remove it?
  2. For logging, should I follow what's in MiniMQTT or what I have here? My thought was that you could:
    • add adafruit_logging.mpy
    • set adafruit_connection_manager.logger.setLevel(0)
    • and logging would show. making it easy for troubleshooting
  3. Can you look at examples/connectionmanager_ssltest.py? I grabbed data from here and not sure what I should put up top in the license info

@tannewt
Copy link
Member

tannewt commented Feb 15, 2024

@tannewt final quick questions.

  1. On another PR you asked to have the for Adafruit removed since I'm not paid for it. But cookie-cutter put it in this. Should I remove it?

I would. I just think that's the default.

  1. For logging, should I follow what's in MiniMQTT or what I have here? My thought was that you could:

    * add `adafruit_logging.mpy`
    * set `adafruit_connection_manager.logger.setLevel(0)`
    * and logging would show. making it easy for troubleshooting
    

I wouldn't. logging takes mpy space and code space if frozen in. It's pretty easy to add logging when it is needed.

  1. Can you look at examples/connectionmanager_ssltest.py? I grabbed data from here and not sure what I should put up top in the license info

I'd just add a comment about the dict with a pointer to the source. It is Apache so it should be ok to include.

@justmobilize
Copy link
Collaborator Author

@tannewt final quick questions.

  1. On another PR you asked to have the for Adafruit removed since I'm not paid for it. But cookie-cutter put it in this. Should I remove it?

I would. I just think that's the default.

  1. For logging, should I follow what's in MiniMQTT or what I have here? My thought was that you could:
    * add `adafruit_logging.mpy`
    * set `adafruit_connection_manager.logger.setLevel(0)`
    * and logging would show. making it easy for troubleshooting
    

I wouldn't. logging takes mpy space and code space if frozen in. It's pretty easy to add logging when it is needed.

  1. Can you look at examples/connectionmanager_ssltest.py? I grabbed data from here and not sure what I should put up top in the license info

I'd just add a comment about the dict with a pointer to the source. It is Apache so it should be ok to include.

@tannewt - so since this will be a core library (and frozen for some boards) I should take all the logging out?

@tannewt
Copy link
Member

tannewt commented Feb 16, 2024

@tannewt - so since this will be a core library (and frozen for some boards) I should take all the logging out?

Ya, I think so. It'll be frozen whenever requests is now.

@justmobilize
Copy link
Collaborator Author

@tannewt this is ready for re-review!!!

@justmobilize
Copy link
Collaborator Author

justmobilize commented Feb 17, 2024

For information:

Sizes before (8.x/9x):

14,976 / 13,438 adafruit_minimqtt.mpy
 8,578 /  8,127 adafruit_requests.mpy
-----
23,554 / 21,565

Sizes after:

14,444 / 12,192 adafruit_minimqtt.mpy
 6,726 /  6,189 adafruit_requests.mpy
 3,183 /  2,875 adafruit_connection_manager.mpy
-----
24,353 / 21,256

adafruit_requests + adafruit_connection_manager increase for 9x: 1,245 bytes
adafruit_minimqtt + adafruit_requests + adafruit_connection_manager decrease for 9x: 301 bytes

@justmobilize justmobilize requested a review from tannewt February 17, 2024 20:49
@tannewt
Copy link
Member

tannewt commented Feb 20, 2024

adafruit_requests + adafruit_connection_manager increase for 9x: 1,245 bytes

We'll want to make sure we can build boards with requests frozen in before updating requests. This PR is ok though.

Copy link
Member

@tannewt tannewt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thank you!

@tannewt tannewt merged commit fe040d8 into main Feb 20, 2024
@justmobilize justmobilize deleted the connection-manager branch February 21, 2024 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants