uht

Minimal HTTP server for MicroPython and CircuitPython: nmattia/uht

Supports HTTP/1.0 request parsing, routing based on method and path, and response generation using asyncio streams.

Example:

import uht

server = uht.HTTPServer()

@server.route("/hello/<name>")
async def greet(req, resp, name):
    await resp.send("Hello, " + name)

server.run("0.0.0.0", 8080)

See the main HTTPServer class for details.

Project README, examples and more: https://github.com/nmattia/uht

Copyright 2025 Nicolas Mattia

  1"""
  2Minimal HTTP server for MicroPython and CircuitPython: `nmattia/uht <https://github.com/nmattia/uht>`_
  3
  4Supports HTTP/1.0 request parsing, routing based on method and path, and response generation using asyncio streams.
  5
  6Example:
  7```python
  8import uht
  9
 10server = uht.HTTPServer()
 11
 12@server.route("/hello/<name>")
 13async def greet(req, resp, name):
 14    await resp.send("Hello, " + name)
 15
 16server.run("0.0.0.0", 8080)
 17```
 18
 19See the main :class:`HTTPServer` class for details.
 20
 21Project README, examples and more: https://github.com/nmattia/uht
 22
 23Copyright 2025 `Nicolas Mattia <https://github.com/nmattia>`_
 24"""
 25
 26import logging
 27import asyncio
 28import gc
 29import errno
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61_log = logging.getLogger("WEB")
 62
 63
 64def _match_url_paths(route_path       , req_path       )                         :
 65    """
 66    Match a request path against a route path and extract any path parameters.
 67
 68    Parameters:
 69        route_path: The route pattern (e.g., b'/user/<id>').
 70        req_path: The requested URL path (e.g., b'/user/42').
 71
 72    Returns:
 73        A list of (parameter name, parameter value) pairs if matched; otherwise None.
 74    """
 75    path_params = []
 76
 77    route_parts = route_path.split(b"/")
 78    req_parts = req_path.split(b"/")
 79
 80    if len(route_parts) != len(req_parts):
 81        return None
 82
 83    # go through the parts, accumulating any path parameters found
 84    # along the way.
 85    for route_part, req_part in zip(route_parts, req_parts):
 86        if route_part.startswith(b"<") and route_part.endswith(b">"):
 87            param_key = route_part[1:-1]
 88            param_val = req_part
 89
 90            path_params.append((param_key, param_val))
 91            continue
 92
 93        if route_part != req_part:
 94            return None
 95
 96    return path_params
 97
 98
 99class HTTPException(Exception):
100    """HTTP protocol exceptions"""
101
102    def __init__(self, code=400):
103        self.code = code
104
105
106# per https://www.rfc-editor.org/rfc/rfc9110#table-4
107_SUPPORTED_METHODS = [
108    b"GET",
109    b"HEAD",
110    b"POST",
111    b"PUT",
112    b"DELETE",
113    b"CONNECT",
114    b"OPTIONS",
115    b"TRACE",
116]
117
118
119def _parse_request_line(line       )                      :
120    """
121    Parse an HTTP request line according to RFC 9112.
122
123    As per https://www.rfc-editor.org/rfc/rfc9112.html#name-request-line
124        request-line   = method SP request-target SP HTTP-version
125    where SP is "single space"
126    where method is defined in https://www.rfc-editor.org/rfc/rfc9110#section-9
127    where request-target is arbitrary for our purposes
128    where HTTP-version is 'HTTP-version  = HTTP-name "/" DIGIT "." DIGIT'
129        (https://www.rfc-editor.org/rfc/rfc9112.html#name-http-version)
130
131    Parameters:
132        line: The raw request line as bytes (e.g., b'GET / HTTP/1.1').
133
134    Returns:
135        A dictionary with 'method', 'target', and 'version', or None if invalid.
136    """
137    fragments = line.split(b" ")
138    if len(fragments) != 3:
139        return None
140
141    if fragments[0] not in _SUPPORTED_METHODS:
142        return None
143
144    if not fragments[1]:
145        return None
146
147    http_version_fragments = fragments[2].split(b"/")
148    if len(http_version_fragments) != 2:
149        return None
150
151    if http_version_fragments[0] != b"HTTP":
152        return None
153
154    version_fragments = http_version_fragments[1].split(b".")
155
156    if len(version_fragments) != 2:
157        return None
158
159    try:
160        version_major = int(version_fragments[0])
161        version_minor = int(version_fragments[1])
162    except ValueError:  # failed to parse as int
163        return None
164
165    return {
166        "method": fragments[0],
167        "target": fragments[1],
168        "version": (version_major, version_minor),
169    }
170
171
172class Request:
173    """HTTP Request class
174
175    :class:`HTTPServer`
176
177    """
178
179    def __init__(self, _reader):
180        self.reader                       = _reader
181        # headers are 'None' until `_read_headers` is called
182        self.headers                            = None
183        self.method        = b""
184        self.path        = b""
185        self.query_string = b""
186        self.version                                  = "1.0"
187        self.params         = {
188            "save_headers": [],
189        }
190
191    async def _read_request_line(self):
192        """
193        Read and parse the HTTP request line from the client.
194
195        Updates self.method, self.path, and self.query_string.
196
197        Raises:
198            HTTPException(400): If the request line is malformed.
199
200        This is a coroutine.
201        """
202        while True:
203            rl_raw = await self.reader.readline()
204            # skip empty lines
205            if rl_raw == b"\r\n" or rl_raw == b"\n":
206                continue
207            break
208
209        rl = _parse_request_line(rl_raw)
210        if not rl:
211            raise HTTPException(400)
212
213        self.method = rl["method"]
214
215        url_frags = rl["target"].split(b"?", 1)
216
217        self.path = url_frags[0]
218        if len(url_frags) > 1:
219            self.query_string = url_frags[1]
220
221    async def _read_headers(self, save_headers=[]):
222        """
223        Read HTTP headers from the stream and store selected ones.
224
225        Parameters:
226            save_headers: List of header names (bytes or strings) to preserve in self.headers.
227
228        Raises:
229            HTTPException(400): If a header line is malformed.
230
231        This is a coroutine.
232        """
233        self.headers = {}
234        while True:
235            gc.collect()
236            line = await self.reader.readline()
237            if line == b"\r\n":
238                break
239            frags = line.split(b":", 1)
240            if len(frags) != 2:
241                raise HTTPException(400)
242
243            if frags[0].lower() in [header.lower() for header in save_headers]:
244                self.headers[frags[0].lower()] = frags[1].strip()
245
246
247class Response:
248    """HTTP Response class"""
249
250    VERSION = b"1.0"  # we only support 1.0
251
252    def __init__(self, _writer):
253        self._writer                       = _writer
254
255        # Request line fields
256
257        self._status_code      = 200
258        self._reason_phrase             = None  # optional as per HTTP spec
259
260        # Set to 'True' once the request line has been sent
261        self._status_line_sent       = False
262
263        # Header fields
264
265        self.headers                 = {}
266        # Set to 'True' once the header lines have been sent
267        self._headers_sent       = False
268
269    async def _ensure_ready_for_body(self):
270        """
271        Ensure the status line and headers are sent before sending a response body.
272
273        Raises:
274            Exception: If headers are sent before the status line.
275
276        This is a coroutine.
277        """
278        status_line_sent = self._status_line_sent
279        headers_sent = self._headers_sent
280
281        if not status_line_sent:
282            if headers_sent:
283                raise Exception("Headers were sent before status line")
284            await self._send_status_line()
285
286        if not headers_sent:
287            await self._send_headers()
288
289    def set_status_code(self, value     ):
290        """
291        Set the HTTP status code to send.
292
293        Parameters:
294            value: Integer status code (e.g., 200, 404).
295
296        Raises:
297            Exception: If the status line has already been sent.
298        """
299        if self._status_line_sent:
300            raise Exception("status line already sent")
301
302        self._status_code = value
303
304    def set_reason_phrase(self, value     ):
305        """
306        Set the optional reason phrase for the HTTP status line.
307
308        Parameters:
309            value: A string like 'OK' or 'NOT FOUND'.
310
311        Raises:
312            Exception: If the status line has already been sent.
313        """
314        if self._status_line_sent:
315            raise Exception("status line already sent")
316
317        self._reason_phrase = value
318
319    async def _send_status_line(self):
320        """
321        Send the HTTP status line to the client.
322
323        Raises:
324            Exception: If the status line has already been sent.
325
326        This is a coroutine.
327        """
328        if self._status_line_sent:
329            raise Exception("status line already sent")
330
331        # even if reason phrase is empty, the "preceding" space must be present
332        # https://www.rfc-editor.org/rfc/rfc9112.html#section-4-9
333
334        sl = "HTTP/%s %s %s\r\n" % (
335            Response.VERSION.decode(),
336            self._status_code,
337            self._reason_phrase or "",
338        )
339        self._writer.write(sl)
340        self._status_line_sent = True
341        await self._writer.drain()
342
343    async def _send_headers(self):
344        """
345        Send all HTTP headers followed by a blank line.
346
347        Raises:
348            Exception: If headers have already been sent.
349
350        This is a coroutine.
351        """
352
353        if self.headers is None:
354            raise Exception("Headers already sent")
355
356        hdrs = ""
357        # Headers
358        for k, v in self.headers.items():
359            hdrs += "%s: %s\r\n" % (k, v)
360        hdrs += "\r\n"
361
362        self._writer.write(hdrs)
363        self._headers_sent = True
364        await self._writer.drain()
365        # Collect garbage after small mallocs
366        gc.collect()
367
368    async def send(self, content, **kwargs):
369        """
370        Send the response body content to the client.
371
372        May be called as many times as needed, the content will be appended to the
373        body.
374
375        Parameters:
376            content: The data to send as response body.
377
378        Raises:
379            Exception: If headers or status line are not ready.
380
381        This is a coroutine.
382        """
383        await self._ensure_ready_for_body()
384
385        self._writer.write(content)
386        await self._writer.drain()
387
388    def add_header(self, key, value):
389        """
390        Add a header to the response.
391
392        Parameters:
393            key: The header name.
394            value: The header value.
395
396        Raises:
397            Exception: If headers have already been sent.
398        """
399        if self._headers_sent:
400            raise Exception("Headers already sent")
401
402        self.headers[key.lower()] = value
403
404
405class HTTPServer:
406    def __init__(self, backlog=16):
407        """
408        HTTPServer class.
409
410        See the :func:`route` decorator for specifying routes.
411        See :func:`run` for starting the server.
412
413        """
414        self._backlog = backlog
415        self._routes              = []
416        self._catch_all_handler = None
417
418    def run(self, host="127.0.0.1", port=8081):
419        """
420        Start the HTTP server (blocking) on the specified host and port and run forever.
421
422        Parameters:
423            host: Interface to bind to (default "127.0.0.1").
424            port: Port number to listen on (default 8081).
425        """
426        asyncio.run(self.arun(host, port))
427
428    def _find_url_handler(self, req)                                          :
429        """
430        Find the registered handler matching the given request method and path.
431
432        Parameters:
433            req: A Request instance.
434
435        Returns:
436            A tuple of (handler, params, path_parameters).
437
438        Raises:
439            HTTPException(404): If no matching path.
440            HTTPException(405): If path matches but method does not.
441            HTTPException(501): For unsupported methods (CONNECT, OPTIONS, TRACE).
442        """
443
444        # we only support basic (GET, PUT, etc) requests
445        if (
446            req.method == b"CONNECT"
447            or req.method == b"OPTIONS"
448            or req.method == b"TRACE"
449        ):
450            raise HTTPException(501)
451
452        # tracks whether there was an exact path match to differentiate
453        # between 404 and 405
454        path_matched = False
455
456        for method, path, handler, params in self._routes:
457            result = _match_url_paths(path, req.path)
458            if result is not None:
459                if method == req.method:
460                    return (handler, params, result)
461
462                path_matched = True
463
464        if self._catch_all_handler:
465            return self._catch_all_handler
466
467        if path_matched:
468            raise HTTPException(405)
469
470        # No handler found
471        raise HTTPException(404)
472
473    async def _handle_connection(self, reader, writer):
474        """
475        Handle a client TCP connection, parse request (HTTP/1.0), call handler, return response.
476
477        Parameters:
478            reader: StreamReader for reading the connection.
479            writer: StreamWriter for writing the connection.
480
481        This is a coroutine.
482        """
483        gc.collect()
484
485        try:
486            req = Request(reader)
487            resp = Response(writer)
488            await req._read_request_line()
489
490            # Find URL handler and parse headers
491            (handler, req_params, path_params) = self._find_url_handler(req)
492            await req._read_headers(req_params.get("save_headers") or [])
493
494            gc.collect()  # free up some memory before the handler runs
495
496            path_param_values = [v.decode() for (_, v) in path_params]
497            await handler(req, resp, *path_param_values)
498
499            # ensure the status line & headers are sent even if there
500            # was no body
501            await resp._ensure_ready_for_body()
502            # Done here
503        except (asyncio.CancelledError, asyncio.TimeoutError):
504            pass
505        except OSError as e:
506            # Do not send response for connection related errors - too late :)
507            # P.S. code 32 - is possible BROKEN PIPE error (TODO: is it true?)
508            if e.args[0] not in (errno.ECONNABORTED, errno.ECONNRESET, 32):
509                _log.exception(f"Connection error: {e}")
510                try:
511                    resp.set_status_code(500)
512                    await resp._ensure_ready_for_body()
513                except Exception as e:
514                    pass
515        except HTTPException as e:
516            try:
517                if req.headers is None:
518                    await req._read_headers()
519                resp.set_status_code(e.code)
520                await resp._ensure_ready_for_body()
521            except Exception as e:
522                _log.exception(
523                    f"Failed to send error after HTTPException. Original error: {e}"
524                )
525        except Exception as e:
526            # Unhandled expection in user's method
527            _log.error(req.path.decode())
528            _log.exception(f"Unhandled exception in user's method. Original error: {e}")
529            try:
530                resp.set_status_code(500)
531                await resp._ensure_ready_for_body()
532            except Exception as e:
533                pass
534        finally:
535            writer.close()
536            await writer.wait_closed()
537
538    def add_route(
539        self,
540        url     ,
541        f,
542        methods            = ["GET"],
543        save_headers                    = [],
544    ):
545        """
546        Register a route handler for a URL pattern and list of HTTP methods.
547
548        Parameters:
549            url: The route path pattern (e.g., '/hello/<name>').
550            f: The handler function (async).
551            methods: List of allowed HTTP methods.
552            save_headers: Headers to preserve from the request.
553        """
554        if url == "" or "?" in url:
555            raise ValueError("Invalid URL")
556        _save_headers = [x.encode() if isinstance(x, str) else x for x in save_headers]
557        _save_headers = [x.lower() for x in _save_headers]
558        # Initial params for route
559        params         = {
560            "save_headers": _save_headers,
561        }
562
563        for method in [x.encode().upper() for x in methods]:
564            self._routes.append((method, url.encode(), f, params))
565
566    def catchall(self):
567        """
568        Decorator to register a catch-all handler when no routes match.
569
570        Returns:
571            A decorator function.
572        """
573        params         = {
574            "save_headers": [],
575        }
576
577        def _route(f):
578            self._catch_all_handler = (f, params, {})
579            return f
580
581        return _route
582
583    def route(self, url, **kwargs):
584        """
585        Decorator to register a route handler.
586
587        Parameters:
588            url: The route path pattern.
589            kwargs: Arguments passed to add_route(), e.g., methods or save_headers.
590
591        Returns:
592            A decorator function.
593        """
594
595        def _route(f):
596            self.add_route(url, f, **kwargs)
597            return f
598
599        return _route
600
601    async def arun(self, host="127.0.0.1", port=8081):
602        """
603        Asynchronously start the server and wait for it to close.
604
605        Parameters:
606            host: Interface to bind to.
607            port: Port number.
608
609        This is a coroutine.
610        """
611        aserver = await self.start(host, port)
612        server = await aserver
613        await server.wait_closed()
614
615    async def start(self, host, port)                                       :
616        """
617        Start the server and return the asyncio.Server instance.
618
619        Parameters:
620            host: Interface to bind to.
621            port: Port number.
622
623        Returns:
624            An asyncio.Server instance.
625        """
626        return asyncio.start_server(
627            self._handle_connection, host, port, backlog=self._backlog
628        )
class HTTPException(builtins.Exception):
100class HTTPException(Exception):
101    """HTTP protocol exceptions"""
102
103    def __init__(self, code=400):
104        self.code = code

HTTP protocol exceptions

HTTPException(code=400)
103    def __init__(self, code=400):
104        self.code = code
code
class Request:
173class Request:
174    """HTTP Request class
175
176    :class:`HTTPServer`
177
178    """
179
180    def __init__(self, _reader):
181        self.reader                       = _reader
182        # headers are 'None' until `_read_headers` is called
183        self.headers                            = None
184        self.method        = b""
185        self.path        = b""
186        self.query_string = b""
187        self.version                                  = "1.0"
188        self.params         = {
189            "save_headers": [],
190        }
191
192    async def _read_request_line(self):
193        """
194        Read and parse the HTTP request line from the client.
195
196        Updates self.method, self.path, and self.query_string.
197
198        Raises:
199            HTTPException(400): If the request line is malformed.
200
201        This is a coroutine.
202        """
203        while True:
204            rl_raw = await self.reader.readline()
205            # skip empty lines
206            if rl_raw == b"\r\n" or rl_raw == b"\n":
207                continue
208            break
209
210        rl = _parse_request_line(rl_raw)
211        if not rl:
212            raise HTTPException(400)
213
214        self.method = rl["method"]
215
216        url_frags = rl["target"].split(b"?", 1)
217
218        self.path = url_frags[0]
219        if len(url_frags) > 1:
220            self.query_string = url_frags[1]
221
222    async def _read_headers(self, save_headers=[]):
223        """
224        Read HTTP headers from the stream and store selected ones.
225
226        Parameters:
227            save_headers: List of header names (bytes or strings) to preserve in self.headers.
228
229        Raises:
230            HTTPException(400): If a header line is malformed.
231
232        This is a coroutine.
233        """
234        self.headers = {}
235        while True:
236            gc.collect()
237            line = await self.reader.readline()
238            if line == b"\r\n":
239                break
240            frags = line.split(b":", 1)
241            if len(frags) != 2:
242                raise HTTPException(400)
243
244            if frags[0].lower() in [header.lower() for header in save_headers]:
245                self.headers[frags[0].lower()] = frags[1].strip()

HTTP Request class

HTTPServer

Request(_reader)
180    def __init__(self, _reader):
181        self.reader                       = _reader
182        # headers are 'None' until `_read_headers` is called
183        self.headers                            = None
184        self.method        = b""
185        self.path        = b""
186        self.query_string = b""
187        self.version                                  = "1.0"
188        self.params         = {
189            "save_headers": [],
190        }
reader
headers
method
path
query_string
version
params
class Response:
248class Response:
249    """HTTP Response class"""
250
251    VERSION = b"1.0"  # we only support 1.0
252
253    def __init__(self, _writer):
254        self._writer                       = _writer
255
256        # Request line fields
257
258        self._status_code      = 200
259        self._reason_phrase             = None  # optional as per HTTP spec
260
261        # Set to 'True' once the request line has been sent
262        self._status_line_sent       = False
263
264        # Header fields
265
266        self.headers                 = {}
267        # Set to 'True' once the header lines have been sent
268        self._headers_sent       = False
269
270    async def _ensure_ready_for_body(self):
271        """
272        Ensure the status line and headers are sent before sending a response body.
273
274        Raises:
275            Exception: If headers are sent before the status line.
276
277        This is a coroutine.
278        """
279        status_line_sent = self._status_line_sent
280        headers_sent = self._headers_sent
281
282        if not status_line_sent:
283            if headers_sent:
284                raise Exception("Headers were sent before status line")
285            await self._send_status_line()
286
287        if not headers_sent:
288            await self._send_headers()
289
290    def set_status_code(self, value     ):
291        """
292        Set the HTTP status code to send.
293
294        Parameters:
295            value: Integer status code (e.g., 200, 404).
296
297        Raises:
298            Exception: If the status line has already been sent.
299        """
300        if self._status_line_sent:
301            raise Exception("status line already sent")
302
303        self._status_code = value
304
305    def set_reason_phrase(self, value     ):
306        """
307        Set the optional reason phrase for the HTTP status line.
308
309        Parameters:
310            value: A string like 'OK' or 'NOT FOUND'.
311
312        Raises:
313            Exception: If the status line has already been sent.
314        """
315        if self._status_line_sent:
316            raise Exception("status line already sent")
317
318        self._reason_phrase = value
319
320    async def _send_status_line(self):
321        """
322        Send the HTTP status line to the client.
323
324        Raises:
325            Exception: If the status line has already been sent.
326
327        This is a coroutine.
328        """
329        if self._status_line_sent:
330            raise Exception("status line already sent")
331
332        # even if reason phrase is empty, the "preceding" space must be present
333        # https://www.rfc-editor.org/rfc/rfc9112.html#section-4-9
334
335        sl = "HTTP/%s %s %s\r\n" % (
336            Response.VERSION.decode(),
337            self._status_code,
338            self._reason_phrase or "",
339        )
340        self._writer.write(sl)
341        self._status_line_sent = True
342        await self._writer.drain()
343
344    async def _send_headers(self):
345        """
346        Send all HTTP headers followed by a blank line.
347
348        Raises:
349            Exception: If headers have already been sent.
350
351        This is a coroutine.
352        """
353
354        if self.headers is None:
355            raise Exception("Headers already sent")
356
357        hdrs = ""
358        # Headers
359        for k, v in self.headers.items():
360            hdrs += "%s: %s\r\n" % (k, v)
361        hdrs += "\r\n"
362
363        self._writer.write(hdrs)
364        self._headers_sent = True
365        await self._writer.drain()
366        # Collect garbage after small mallocs
367        gc.collect()
368
369    async def send(self, content, **kwargs):
370        """
371        Send the response body content to the client.
372
373        May be called as many times as needed, the content will be appended to the
374        body.
375
376        Parameters:
377            content: The data to send as response body.
378
379        Raises:
380            Exception: If headers or status line are not ready.
381
382        This is a coroutine.
383        """
384        await self._ensure_ready_for_body()
385
386        self._writer.write(content)
387        await self._writer.drain()
388
389    def add_header(self, key, value):
390        """
391        Add a header to the response.
392
393        Parameters:
394            key: The header name.
395            value: The header value.
396
397        Raises:
398            Exception: If headers have already been sent.
399        """
400        if self._headers_sent:
401            raise Exception("Headers already sent")
402
403        self.headers[key.lower()] = value

HTTP Response class

Response(_writer)
253    def __init__(self, _writer):
254        self._writer                       = _writer
255
256        # Request line fields
257
258        self._status_code      = 200
259        self._reason_phrase             = None  # optional as per HTTP spec
260
261        # Set to 'True' once the request line has been sent
262        self._status_line_sent       = False
263
264        # Header fields
265
266        self.headers                 = {}
267        # Set to 'True' once the header lines have been sent
268        self._headers_sent       = False
VERSION = b'1.0'
headers
def set_status_code(self, value):
290    def set_status_code(self, value     ):
291        """
292        Set the HTTP status code to send.
293
294        Parameters:
295            value: Integer status code (e.g., 200, 404).
296
297        Raises:
298            Exception: If the status line has already been sent.
299        """
300        if self._status_line_sent:
301            raise Exception("status line already sent")
302
303        self._status_code = value

Set the HTTP status code to send.

Parameters: value: Integer status code (e.g., 200, 404).

Raises: Exception: If the status line has already been sent.

def set_reason_phrase(self, value):
305    def set_reason_phrase(self, value     ):
306        """
307        Set the optional reason phrase for the HTTP status line.
308
309        Parameters:
310            value: A string like 'OK' or 'NOT FOUND'.
311
312        Raises:
313            Exception: If the status line has already been sent.
314        """
315        if self._status_line_sent:
316            raise Exception("status line already sent")
317
318        self._reason_phrase = value

Set the optional reason phrase for the HTTP status line.

Parameters: value: A string like 'OK' or 'NOT FOUND'.

Raises: Exception: If the status line has already been sent.

async def send(self, content, **kwargs):
369    async def send(self, content, **kwargs):
370        """
371        Send the response body content to the client.
372
373        May be called as many times as needed, the content will be appended to the
374        body.
375
376        Parameters:
377            content: The data to send as response body.
378
379        Raises:
380            Exception: If headers or status line are not ready.
381
382        This is a coroutine.
383        """
384        await self._ensure_ready_for_body()
385
386        self._writer.write(content)
387        await self._writer.drain()

Send the response body content to the client.

May be called as many times as needed, the content will be appended to the body.

Parameters: content: The data to send as response body.

Raises: Exception: If headers or status line are not ready.

This is a coroutine.

def add_header(self, key, value):
389    def add_header(self, key, value):
390        """
391        Add a header to the response.
392
393        Parameters:
394            key: The header name.
395            value: The header value.
396
397        Raises:
398            Exception: If headers have already been sent.
399        """
400        if self._headers_sent:
401            raise Exception("Headers already sent")
402
403        self.headers[key.lower()] = value

Add a header to the response.

Parameters: key: The header name. value: The header value.

Raises: Exception: If headers have already been sent.

class HTTPServer:
406class HTTPServer:
407    def __init__(self, backlog=16):
408        """
409        HTTPServer class.
410
411        See the :func:`route` decorator for specifying routes.
412        See :func:`run` for starting the server.
413
414        """
415        self._backlog = backlog
416        self._routes              = []
417        self._catch_all_handler = None
418
419    def run(self, host="127.0.0.1", port=8081):
420        """
421        Start the HTTP server (blocking) on the specified host and port and run forever.
422
423        Parameters:
424            host: Interface to bind to (default "127.0.0.1").
425            port: Port number to listen on (default 8081).
426        """
427        asyncio.run(self.arun(host, port))
428
429    def _find_url_handler(self, req)                                          :
430        """
431        Find the registered handler matching the given request method and path.
432
433        Parameters:
434            req: A Request instance.
435
436        Returns:
437            A tuple of (handler, params, path_parameters).
438
439        Raises:
440            HTTPException(404): If no matching path.
441            HTTPException(405): If path matches but method does not.
442            HTTPException(501): For unsupported methods (CONNECT, OPTIONS, TRACE).
443        """
444
445        # we only support basic (GET, PUT, etc) requests
446        if (
447            req.method == b"CONNECT"
448            or req.method == b"OPTIONS"
449            or req.method == b"TRACE"
450        ):
451            raise HTTPException(501)
452
453        # tracks whether there was an exact path match to differentiate
454        # between 404 and 405
455        path_matched = False
456
457        for method, path, handler, params in self._routes:
458            result = _match_url_paths(path, req.path)
459            if result is not None:
460                if method == req.method:
461                    return (handler, params, result)
462
463                path_matched = True
464
465        if self._catch_all_handler:
466            return self._catch_all_handler
467
468        if path_matched:
469            raise HTTPException(405)
470
471        # No handler found
472        raise HTTPException(404)
473
474    async def _handle_connection(self, reader, writer):
475        """
476        Handle a client TCP connection, parse request (HTTP/1.0), call handler, return response.
477
478        Parameters:
479            reader: StreamReader for reading the connection.
480            writer: StreamWriter for writing the connection.
481
482        This is a coroutine.
483        """
484        gc.collect()
485
486        try:
487            req = Request(reader)
488            resp = Response(writer)
489            await req._read_request_line()
490
491            # Find URL handler and parse headers
492            (handler, req_params, path_params) = self._find_url_handler(req)
493            await req._read_headers(req_params.get("save_headers") or [])
494
495            gc.collect()  # free up some memory before the handler runs
496
497            path_param_values = [v.decode() for (_, v) in path_params]
498            await handler(req, resp, *path_param_values)
499
500            # ensure the status line & headers are sent even if there
501            # was no body
502            await resp._ensure_ready_for_body()
503            # Done here
504        except (asyncio.CancelledError, asyncio.TimeoutError):
505            pass
506        except OSError as e:
507            # Do not send response for connection related errors - too late :)
508            # P.S. code 32 - is possible BROKEN PIPE error (TODO: is it true?)
509            if e.args[0] not in (errno.ECONNABORTED, errno.ECONNRESET, 32):
510                _log.exception(f"Connection error: {e}")
511                try:
512                    resp.set_status_code(500)
513                    await resp._ensure_ready_for_body()
514                except Exception as e:
515                    pass
516        except HTTPException as e:
517            try:
518                if req.headers is None:
519                    await req._read_headers()
520                resp.set_status_code(e.code)
521                await resp._ensure_ready_for_body()
522            except Exception as e:
523                _log.exception(
524                    f"Failed to send error after HTTPException. Original error: {e}"
525                )
526        except Exception as e:
527            # Unhandled expection in user's method
528            _log.error(req.path.decode())
529            _log.exception(f"Unhandled exception in user's method. Original error: {e}")
530            try:
531                resp.set_status_code(500)
532                await resp._ensure_ready_for_body()
533            except Exception as e:
534                pass
535        finally:
536            writer.close()
537            await writer.wait_closed()
538
539    def add_route(
540        self,
541        url     ,
542        f,
543        methods            = ["GET"],
544        save_headers                    = [],
545    ):
546        """
547        Register a route handler for a URL pattern and list of HTTP methods.
548
549        Parameters:
550            url: The route path pattern (e.g., '/hello/<name>').
551            f: The handler function (async).
552            methods: List of allowed HTTP methods.
553            save_headers: Headers to preserve from the request.
554        """
555        if url == "" or "?" in url:
556            raise ValueError("Invalid URL")
557        _save_headers = [x.encode() if isinstance(x, str) else x for x in save_headers]
558        _save_headers = [x.lower() for x in _save_headers]
559        # Initial params for route
560        params         = {
561            "save_headers": _save_headers,
562        }
563
564        for method in [x.encode().upper() for x in methods]:
565            self._routes.append((method, url.encode(), f, params))
566
567    def catchall(self):
568        """
569        Decorator to register a catch-all handler when no routes match.
570
571        Returns:
572            A decorator function.
573        """
574        params         = {
575            "save_headers": [],
576        }
577
578        def _route(f):
579            self._catch_all_handler = (f, params, {})
580            return f
581
582        return _route
583
584    def route(self, url, **kwargs):
585        """
586        Decorator to register a route handler.
587
588        Parameters:
589            url: The route path pattern.
590            kwargs: Arguments passed to add_route(), e.g., methods or save_headers.
591
592        Returns:
593            A decorator function.
594        """
595
596        def _route(f):
597            self.add_route(url, f, **kwargs)
598            return f
599
600        return _route
601
602    async def arun(self, host="127.0.0.1", port=8081):
603        """
604        Asynchronously start the server and wait for it to close.
605
606        Parameters:
607            host: Interface to bind to.
608            port: Port number.
609
610        This is a coroutine.
611        """
612        aserver = await self.start(host, port)
613        server = await aserver
614        await server.wait_closed()
615
616    async def start(self, host, port)                                       :
617        """
618        Start the server and return the asyncio.Server instance.
619
620        Parameters:
621            host: Interface to bind to.
622            port: Port number.
623
624        Returns:
625            An asyncio.Server instance.
626        """
627        return asyncio.start_server(
628            self._handle_connection, host, port, backlog=self._backlog
629        )
HTTPServer(backlog=16)
407    def __init__(self, backlog=16):
408        """
409        HTTPServer class.
410
411        See the :func:`route` decorator for specifying routes.
412        See :func:`run` for starting the server.
413
414        """
415        self._backlog = backlog
416        self._routes              = []
417        self._catch_all_handler = None

HTTPServer class.

See the route() decorator for specifying routes. See run() for starting the server.

def run(self, host='127.0.0.1', port=8081):
419    def run(self, host="127.0.0.1", port=8081):
420        """
421        Start the HTTP server (blocking) on the specified host and port and run forever.
422
423        Parameters:
424            host: Interface to bind to (default "127.0.0.1").
425            port: Port number to listen on (default 8081).
426        """
427        asyncio.run(self.arun(host, port))

Start the HTTP server (blocking) on the specified host and port and run forever.

Parameters: host: Interface to bind to (default "127.0.0.1"). port: Port number to listen on (default 8081).

def add_route(self, url, f, methods=['GET'], save_headers=[]):
539    def add_route(
540        self,
541        url     ,
542        f,
543        methods            = ["GET"],
544        save_headers                    = [],
545    ):
546        """
547        Register a route handler for a URL pattern and list of HTTP methods.
548
549        Parameters:
550            url: The route path pattern (e.g., '/hello/<name>').
551            f: The handler function (async).
552            methods: List of allowed HTTP methods.
553            save_headers: Headers to preserve from the request.
554        """
555        if url == "" or "?" in url:
556            raise ValueError("Invalid URL")
557        _save_headers = [x.encode() if isinstance(x, str) else x for x in save_headers]
558        _save_headers = [x.lower() for x in _save_headers]
559        # Initial params for route
560        params         = {
561            "save_headers": _save_headers,
562        }
563
564        for method in [x.encode().upper() for x in methods]:
565            self._routes.append((method, url.encode(), f, params))

Register a route handler for a URL pattern and list of HTTP methods.

Parameters: url: The route path pattern (e.g., '/hello/'). f: The handler function (async). methods: List of allowed HTTP methods. save_headers: Headers to preserve from the request.

def catchall(self):
567    def catchall(self):
568        """
569        Decorator to register a catch-all handler when no routes match.
570
571        Returns:
572            A decorator function.
573        """
574        params         = {
575            "save_headers": [],
576        }
577
578        def _route(f):
579            self._catch_all_handler = (f, params, {})
580            return f
581
582        return _route

Decorator to register a catch-all handler when no routes match.

Returns: A decorator function.

def route(self, url, **kwargs):
584    def route(self, url, **kwargs):
585        """
586        Decorator to register a route handler.
587
588        Parameters:
589            url: The route path pattern.
590            kwargs: Arguments passed to add_route(), e.g., methods or save_headers.
591
592        Returns:
593            A decorator function.
594        """
595
596        def _route(f):
597            self.add_route(url, f, **kwargs)
598            return f
599
600        return _route

Decorator to register a route handler.

Parameters: url: The route path pattern. kwargs: Arguments passed to add_route(), e.g., methods or save_headers.

Returns: A decorator function.

async def arun(self, host='127.0.0.1', port=8081):
602    async def arun(self, host="127.0.0.1", port=8081):
603        """
604        Asynchronously start the server and wait for it to close.
605
606        Parameters:
607            host: Interface to bind to.
608            port: Port number.
609
610        This is a coroutine.
611        """
612        aserver = await self.start(host, port)
613        server = await aserver
614        await server.wait_closed()

Asynchronously start the server and wait for it to close.

Parameters: host: Interface to bind to. port: Port number.

This is a coroutine.

async def start(self, host, port):
616    async def start(self, host, port)                                       :
617        """
618        Start the server and return the asyncio.Server instance.
619
620        Parameters:
621            host: Interface to bind to.
622            port: Port number.
623
624        Returns:
625            An asyncio.Server instance.
626        """
627        return asyncio.start_server(
628            self._handle_connection, host, port, backlog=self._backlog
629        )

Start the server and return the asyncio.Server instance.

Parameters: host: Interface to bind to. port: Port number.

Returns: An asyncio.Server instance.