Skip to content

Commit ae4feac

Browse files
committed
full non blocking with select
1 parent 62b1d53 commit ae4feac

File tree

4 files changed

+675
-54
lines changed

4 files changed

+675
-54
lines changed

README.md

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
## Features
55

66
- MicroPython and CPython compatible
7-
- Select-based async (no async/await, no threading)
7+
- Fully non-blocking: TCP connect, SSL handshake, and HTTP I/O via select
88
- Keep-alive connections with automatic reuse
99
- Fluent API: `response = client.get('/path').wait()`
1010
- URL parsing with automatic SSL detection
@@ -143,18 +143,18 @@ client.close()
143143

144144
## Async (non-blocking) mode
145145

146-
Default mode is async. Use with external select loop:
146+
Everything is non-blocking by default — TCP connect, SSL handshake, and HTTP I/O all happen through `select()`. This is critical for embedded devices on slow networks (4G modems, ESP32 PPP) where each phase can take seconds.
147147

148148
```python
149149
import select
150150
import uhttp.client
151151

152152
client = uhttp.client.HttpClient('http://httpbin.org')
153153

154-
# Start request (non-blocking)
154+
# Start request (non-blocking, including connect)
155155
client.get('/delay/2')
156156

157-
# Manual select loop
157+
# Manual select loop - handles connect, send, and receive
158158
while True:
159159
r, w, _ = select.select(
160160
client.read_sockets,
@@ -170,8 +170,25 @@ while True:
170170
client.close()
171171
```
172172

173+
### State machine
174+
175+
After `client.get('/path')`, the client progresses through states automatically via `process_events()`:
176+
177+
| State | Description | select watches |
178+
|---|---|---|
179+
| `STATE_CONNECTING` | TCP connect in progress | write |
180+
| `STATE_SSL_HANDSHAKE` | SSL handshake in progress | read or write |
181+
| `STATE_SENDING` | Sending request data | write |
182+
| `STATE_RECEIVING_HEADERS` | Waiting for response headers | read |
183+
| `STATE_RECEIVING_BODY` | Receiving response body | read |
184+
| `STATE_COMPLETE` | Response ready ||
185+
186+
The `state` property exposes the current state. The `is_connected` property returns `True` only after connect and handshake are complete.
187+
173188
### Parallel requests
174189

190+
All clients share one select loop. Connect, handshake, and data transfer happen concurrently:
191+
175192
```python
176193
import select
177194
import uhttp.client
@@ -182,11 +199,11 @@ clients = [
182199
uhttp.client.HttpClient('http://httpbin.org'),
183200
]
184201

185-
# Start all requests
202+
# Start all requests (non-blocking connects begin immediately)
186203
for i, client in enumerate(clients):
187204
client.get('/delay/1', query={'n': i})
188205

189-
# Wait for all
206+
# Single select loop handles all clients
190207
results = {}
191208
while len(results) < len(clients):
192209
read_socks = []
@@ -209,6 +226,8 @@ for client in clients:
209226

210227
### Combined with HttpServer
211228

229+
Server and client in the same select loop — true single-threaded concurrency:
230+
212231
```python
213232
import select
214233
import uhttp.server
@@ -235,6 +254,72 @@ while True:
235254
incoming.respond(data=response.data)
236255
```
237256

257+
### HTTPS with non-blocking handshake
258+
259+
SSL handshake is also non-blocking. The client tracks whether `do_handshake()` needs to read or write, and exposes the socket only in the correct direction to prevent `select()` from spinning:
260+
261+
```python
262+
import select
263+
import ssl
264+
import uhttp.client
265+
266+
ctx = ssl.create_default_context()
267+
client = uhttp.client.HttpClient(
268+
'api.example.com', port=443, ssl_context=ctx)
269+
270+
# Connect + SSL handshake + request all happen via select
271+
client.get('/data')
272+
273+
while True:
274+
r, w, _ = select.select(
275+
client.read_sockets,
276+
client.write_sockets,
277+
[], 10.0
278+
)
279+
response = client.process_events(r, w)
280+
if response:
281+
print(response.json())
282+
break
283+
284+
client.close()
285+
```
286+
287+
### Multiple HTTPS clients in parallel
288+
289+
```python
290+
import select
291+
import uhttp.client
292+
293+
urls = [
294+
'https://api1.example.com/data',
295+
'https://api2.example.com/data',
296+
'https://api3.example.com/data',
297+
]
298+
299+
clients = [uhttp.client.HttpClient(url) for url in urls]
300+
for c in clients:
301+
c.get('/') # All start non-blocking connects + SSL handshakes
302+
303+
responses = [None] * len(clients)
304+
while not all(responses):
305+
read_socks = []
306+
write_socks = []
307+
for c in clients:
308+
read_socks.extend(c.read_sockets)
309+
write_socks.extend(c.write_sockets)
310+
311+
r, w, _ = select.select(read_socks, write_socks, [], 10.0)
312+
313+
for i, c in enumerate(clients):
314+
if responses[i] is None:
315+
resp = c.process_events(r, w)
316+
if resp:
317+
responses[i] = resp
318+
319+
for c in clients:
320+
c.close()
321+
```
322+
238323

239324
## API
240325

@@ -294,8 +379,8 @@ Parameters:
294379
- `host` - Server hostname
295380
- `port` - Server port
296381
- `base_path` - Base path from URL (prepended to all request paths)
297-
- `is_connected` - True if socket is connected
298-
- `state` - Current state (STATE_IDLE, STATE_SENDING, etc.)
382+
- `is_connected` - True if TCP (and SSL) connection is fully established
383+
- `state` - Current state (STATE_IDLE, STATE_CONNECTING, STATE_SSL_HANDSHAKE, STATE_SENDING, etc.)
299384
- `auth` - Authentication credentials tuple (username, password) or None
300385
- `cookies` - Cookies dict (persistent across requests)
301386
- `read_sockets` - Sockets to monitor for reading (for select)
@@ -487,11 +572,26 @@ client.close()
487572

488573
## Timeouts
489574

490-
Two types of timeouts:
575+
Three types of timeouts:
576+
577+
### Connect timeout
578+
579+
Time allowed for TCP connect + SSL handshake. Set via `connect_timeout` parameter (default: 10s).
580+
When expired during connect/handshake phase, raises `HttpTimeoutError`.
581+
582+
```python
583+
import uhttp.client
584+
585+
# Short connect timeout for fast-fail on unreachable hosts
586+
client = uhttp.client.HttpClient('https://example.com', connect_timeout=3)
587+
588+
# Long connect timeout for slow 4G/satellite links
589+
client = uhttp.client.HttpClient('https://example.com', connect_timeout=30)
590+
```
491591

492592
### Request timeout
493593

494-
Total time allowed for the request. Set via `timeout` parameter on client or per-request.
594+
Total time allowed for the entire request (including connect). Set via `timeout` parameter on client or per-request.
495595
When expired, raises `HttpTimeoutError` and closes connection.
496596

497597
```python
@@ -504,6 +604,8 @@ client = uhttp.client.HttpClient('https://example.com', timeout=30)
504604
response = client.get('/slow', timeout=60).wait()
505605
```
506606

607+
Both `connect_timeout` and `timeout` are checked during connect/handshake phases — whichever expires first triggers `HttpTimeoutError`.
608+
507609
### Wait timeout
508610

509611
Time to spend in `wait()` call. When expired, returns `None` but keeps connection open.

tests/test_async.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,10 @@ def test_state_transitions(self):
8484
# Start request
8585
client.get('/test')
8686

87-
# Should be sending or receiving (depending on how fast)
87+
# Should be connecting, sending or receiving (depending on how fast)
8888
self.assertIn(client.state, [
89+
uhttp_client.STATE_CONNECTING,
90+
uhttp_client.STATE_SSL_HANDSHAKE,
8991
uhttp_client.STATE_SENDING,
9092
uhttp_client.STATE_RECEIVING_HEADERS,
9193
uhttp_client.STATE_RECEIVING_BODY,

0 commit comments

Comments
 (0)