Published: 2025-08-06 15:03:00-06:00

Cloudflare Python Workers (beta): Idiosyncracies II

Background

Previously I covered the first efforts I made with Python Workers and detailed some of the issues I ran in to and how I tackled them. This article will focus more on the actual Python code itself and how I chose to handle the functionality I needed. Please note that while I consider myself a functional software developer, and I do like it, I don't consider myself a particularly great developer, so much of my code is probably inefficient and implemented in a better fashion. With that being said, I enjoy solving my own problems, and the "DIY" aspect is more valuable to me from both a learning as well as ownership perspectives.

So for this article, I'll provide some detail around the setup for the worker and the initial route handling.

The Challenges

Problem Statement

Challenge: collect email addresses for a new project and allow them to be verified

Obviously this has been a tried and true process for years, and in reality I could just have embedded a form from something like MailChimp but I had two main things that discouraged me from doing so: propriety and uncertainty. The propriety issue is that I want to control my own data. I don't want to share it with some third party and these days I don't feel like there's any guarantee that if the project goes nowhere I can effectively and permanently delete people's data. The uncertainty issue is that I don't know that the project will take off and I don't want potential users to be left with the notion that I'm going to sell their email for profit... or that someone else will.

Tools

Obviously email is necessary for the process, and not at a high volume (initially) so after some research, I ended up using Resend which has a nicely documented, easy to use REST API - this was important in context because while Resend (and others) have pre-built python packages, the Python Workers beta relies on pyodide so the only packages available are pyodide's built-ins and while there are tons of them, none really support external mail APIs.

Secondarily, and as previously mentioned, I needed a way to store and update some simple data. I picked Cloudflare Workers KV as it supports native bindings in python and my data needs are not terribly complex for the project. I was late to the game reading about pywrangler for usage with python workers, so I ended up using regular wrangler which probably caused me more stress than I needed, but forced me to learn some of the unique ins and outs of Cloudflare Workers, Pyodide, and the Python Workers implementation.

Gotchas

If I had gone down the route of pywrangler, I might have still run into limitations around the Python Workers beta that might have required me to fall back to some fun items anyway - in theory I could have used httpx and FastAPI instead of building my own HTTP route handler and using pyodide's FFI. I'd like to say I'll revisit this and try and implement using those things, but what I built works, it's observable, and until Python Workers are out of beta and a first class citizen, that's unlikely to happen.

The Implementation

Pyodide's limited stdlib availability does at least included urllib, which was going to be necessary, so that was included

from urllib.parse import urlparse, parse_qs, quote, unquote, urlencode

And Cloudflare's workers packages re-exports some of the pyodide FFI functions, which I needed:

from workers import handler, Response, Request, fetch

Next up we need to handle our entry into our python script. Workers must enter through on_fetch, so it performs two functions for me: setting up logging for the request and handing off the request to the route handler. Remember that in Part I I wrote about using the @handler wrapper, and that's what's done here:

# This is the entry point for the worker - on_fetch is the
# mandatory entry point for the worker to handle incoming requests.
@handler
async def on_fetch(request: Request, env: any) -> Response:
    try:
        # Set up logging based on environment variables
        if hasattr(env, 'LOG_LEVEL'):
            log.warning(f"Setting log level to {env.LOG_LEVEL}")
            log.setLevel(level=int(env.LOG_LEVEL))
    except Exception as e:
        log.error(f"Error setting up logging: {e}")
        log.setLevel(level=logging.DEBUG)

    # pass off our request to the route handler
    return await route_handler(request, env)

My creatively named route handler does a few things - logs the request (if I've set the log level to debug), parses the url, and verifies that the worker is being executed on the correct worker route which I've defined elsewhere, and kicks out a 404 if that doesn't match.

Then we use structural pattern matching to handle our methods. In my worker, only POST and GET are handled, so I return a 400 (defined elsewhere as HTTP_ERRORS for reuse) for everything else.

async def route_handler(request: Request, env: any) -> Response:
    log.debug(f"Handling request: {request.method} {request.url}")
    parsed_url = urlparse(request.url)
    if not parsed_url.path.startswith(prefix_route):
        return Response.json({"error": "Not Found"}, status=404)

    # structural pattern matching is our friend
    match request.method:
        case "POST":
            return await handle_post(request, env)
        case "GET":
            return await handle_get(request, env, parsed_url)
        case _:
            return Response.json({"error": HTTP_ERRORS.INVALID_REQUEST.msg},
                            status=HTTP_ERRORS.INVALID_REQUEST.code)

Part II Wrap-up

With this, you can see the python worker starting to come together. We've done our set up, handled our worker's predefined entry point with on_fetch and handed off the request to our self-built router. Next article will explore the functions used to handle the requests specifically, as well as the interaction