dánwòdánwò

Real stacks

Other languages

How danwo routes a Python, Go or gRPC app through its proxy with the standard proxy and CA variables, what each common client reads, and what differs from a Node app.

An app in Python, Go, Ruby or any other language runs behind danwo's proxy just like a Node app. danwo can't load its Node preload into it, so it sets the standard proxy and certificate variables instead, which most HTTP clients read on their own.

Why it works without code changes

Your app's calls to Stripe or Resend go to the proxy because its HTTP client reads HTTPS_PROXY. They pass the TLS check because the client trusts the CA bundle danwo names. Nothing in your app mentions danwo.

danwo.config.ts · a FastAPI app
{
  name: "api",
  command: "uv run --no-sync uvicorn studio.app:app --host 127.0.0.1 --port 8000",
  cwd: "api",
  url: "http://127.0.0.1:8000",
  ready: { path: "/health" },
  env: { DATABASE_URL: postgres.url(database), STRIPE_SECRET_KEY: "sk_test_studio" },
}

The Python example and the Go example run whole apps this way, fault matrix included.

What danwo sets

Every app danwo starts gets these, Node or not:

VariableValueRead by
HTTPS_PROXY, https_proxythe proxy, http://127.0.0.1:<port>almost every client; curl wants lower case
HTTP_PROXY, http_proxythe sameclients making plain HTTP calls
NO_PROXY, no_proxyyour own NO_PROXY, then the proxy's ownevery client that reads the two above
SSL_CERT_FILEthe CA bundleOpenSSL: Python's ssl, httpx, Ruby, PHP, Go on Linux
REQUESTS_CA_BUNDLEthe CA bundlePython's requests, which ignores SSL_CERT_FILE
CURL_CA_BUNDLEthe CA bundlecurl, and requests when the one above is unset
GRPC_DEFAULT_SSL_ROOTS_FILE_PATHthe CA bundlegRPC in Python, Ruby and PHP, and grpc-js in Node
NODE_EXTRA_CA_CERTSdanwo's CA aloneNode, which adds it to its own roots

Windows' own curl checks certificates against the Windows certificate store and ignores the CA bundle variable, so it can't trust danwo's CA from the environment.

The CA bundle is danwo's CA followed by the public roots. These variables replace a client's roots rather than add to them, so a host your app reaches directly, past the proxy through your NO_PROXY, still verifies.

Python

Python's HTTP clients read the variables themselves, so most Python apps need no change at all.

What each client reads

ClientGoes through the proxyTrusts danwo's CA
requestsyesyes, through REQUESTS_CA_BUNDLE
httpx (and the OpenAI and Anthropic SDKs)yesyes, through SSL_CERT_FILE
urllib.requestyesyes, through SSL_CERT_FILE
aiohttponly with trust_env=Trueyes, through SSL_CERT_FILE
the Resend SDKyes, it uses requestsyes
the Stripe SDKyesno: it ships its own bundle, see below

The Stripe SDK

Stripe's Python SDK verifies against the bundle it ships and reads no variable for another one. Its calls reach the proxy and then fail with Could not verify Stripe's SSL certificate. Point it at the bundle the environment names, as an app behind a company's TLS proxy would:

api/studio/payments.py · the CA bundle
import os

import stripe

ca_bundle = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("SSL_CERT_FILE")
if ca_bundle:
    stripe.ca_bundle_path = ca_bundle

In production neither variable is usually set, so the SDK keeps its own bundle.

Keep the start command offline

Install the app's packages before danwo starts it, and start it with a command that installs nothing. danwo's proxy routes the start command too, so a package download meets the proxy, and under unmatched: "block" it is refused. With uv, run uv sync --frozen first and start the app with uv run --no-sync.

Put a Node frontend in front of it

Most Python APIs sit behind a site in Node. List both apps in one stack, and wire the site to the API by its address:

danwo.config.ts · a SvelteKit site on the API
{
  name: "web",
  command: "node server.ts",
  cwd: "web",
  url: "http://127.0.0.1:3000",
  ready: { path: "/health" },
  dependsOn: ["api"],
  env: ({ api = "" }) => ({ API_URL: api, PUBLIC_API_URL: api }),
}

The site runs in Node, so it gets the preload, and the API gets the variables above. The example serves the site's build with web/server.ts, adapter-node's handler on plain HTTP, since node build takes every request to be HTTPS. Both apps are routed through one proxy:

CallRecorded asFaulted by the matrix
the site's server calling the APIserver, passed onno: it is your own app
the browser calling the APIbrowser, passed onwith pageCalls: true
the API calling Stripe or Resendserver, routedyes

Readiness waits for every one of them, so a test never asserts while the site is still waiting on the API. The Python example's matrix plans 54 faults, two of them on the confirmation page asking the API whether the booking exists.

Go

A Go app needs no preload either: the default transport of net/http follows HTTPS_PROXY, so its calls to Stripe, Resend and the rest reach danwo's emulators as soon as danwo starts it. What Go needs is to trust danwo's certificate authority, and on macOS that takes a few lines in the app.

Trust danwo's CA

On Linux, Go reads SSL_CERT_FILE and trusts the certificates in it. On macOS and Windows it asks the operating system to verify certificates and ignores the variable, so a call through danwo's proxy fails with x509: certificate signed by unknown authority. Add the file to a copy of the system's pool when the variable is set, and use that client for your SDKs:

trust.go
func outboundClient() (*http.Client, error) {
	transport := http.DefaultTransport.(*http.Transport).Clone()
	if file := os.Getenv("SSL_CERT_FILE"); file != "" {
		pem, err := os.ReadFile(file)
		if err != nil {
			return nil, err
		}
		roots, err := x509.SystemCertPool()
		if err != nil {
			roots = x509.NewCertPool()
		}
		if !roots.AppendCertsFromPEM(pem) {
			return nil, fmt.Errorf("%s holds no PEM certificate", file)
		}
		transport.TLSClientConfig = &tls.Config{RootCAs: roots}
	}
	return &http.Client{Transport: transport, Timeout: 30 * time.Second}, nil
}

In production the variable is unset, and the client trusts what Go's default one does. Hand the client to each SDK:

SDKWhere the client goes
stripe-gostripe.NewBackendsWithConfig(&stripe.BackendConfig{HTTPClient: client})
resend-goresend.NewCustomClient(client, apiKey)
your own callsclient.Do(req)

Start it from the config

Build once, then run the binary. danwo passes the port as PORT:

danwo.config.ts · the app
app: {
  command: `exec ./dist/app`,
  url,
  ready: { path: "/health" },
  env: { HOST: "127.0.0.1", DATABASE_URL: postgres.url(database.url) },
},

Run go build before the app starts, in the config's environment, behind a lock so parallel workers don't each compile.

Serve a single-page app from it

Many Go products serve their frontend's built files and the JSON API from one server, on one origin. The Go example does this with a Vue app: the session is a cookie, and there is no CORS to set up. Build the page with Vite behind the same lock as go build, and point the server at Vite's output. Every path the page's router owns gets index.html:

api/web.go · the router's paths, trimmed
func (a *webApp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
	if info, err := fs.Stat(a.files, name); err == nil && !info.IsDir() {
		a.serve.ServeHTTP(w, r) // a file Vite wrote
		return
	}
	http.ServeFileFS(w, r, a.files, "index.html") // /account, /checkout/return, …
}

The page's calls to the API come from danwo's browser, so they go through the proxy whatever language the server is in. The flight record shows each one, and the matrix faults them as it does a provider call: a 500, a dropped connection, and an answer lost after the API acted.

gRPC

gRPC runs on HTTP/2, and danwo's proxy speaks it. A gRPC client goes through the proxy like any other call: danwo answers it from a route, or passes it on to the real server. Unary calls, streams in either direction and the grpc-status trailers all come through.

Your gRPC library reads the variables danwo already sets, so most gRPC apps need no change:

LibraryGoes through the proxyTrusts danwo's CA
grpc-js (Node; Google Cloud's Node libraries)yes, through https_proxyyes, through NODE_EXTRA_CA_CERTS
grpcio (Python; Google Cloud's Python ones)yes, through https_proxyyes, through GRPC_DEFAULT_SSL_ROOTS_FILE_PATH
grpc-goyes, through HTTPS_PROXYon Linux; on macOS and Windows, add the CA (below)

grpc-js and grpcio read grpc_proxy first. If your app sets it, unset it for tests, so its calls go to danwo's proxy.

A Node gRPC client

Nothing danwo-specific goes in the code. A client generated from your .proto file is routed as it is:

greeter.ts · no change for danwo
import { credentials } from "@grpc/grpc-js"

const client = new GreeterClient("greeter.example.com:443", credentials.createSsl())

createSsl() with no roots of its own trusts Node's, which hold danwo's CA during a run.

A Go gRPC client

grpc-go follows HTTPS_PROXY, as net/http does. On macOS and Windows it needs the same pool as Trust danwo's CA above, passed to its credentials:

grpc.go · the same roots for gRPC
creds := credentials.NewTLS(&tls.Config{RootCAs: roots})
conn, err := grpc.NewClient("greeter.example.com:443", grpc.WithTransportCredentials(creds))

What to know

  • A client that offers only HTTP/2 gets HTTP/2. Browsers and HTTP SDKs that offer HTTP/1.1 too still get HTTP/1.1.
  • A fault in the matrix hits one call. A reset resets that call's stream, and the other calls on the same connection carry on.
  • A gRPC call can stay open for as long as the app listens, so readiness doesn't wait for it to end.
  • A route's handler can stream a gRPC call both ways. It reads the request body as the app sends it, and its response body goes out as it streams. Put grpc-status in its headers, or decide it at the end with withTrailers, below.
  • If the real server closes a connection just as a call goes out, danwo sends the call again on a fresh connection.
greeter-emulator.ts · a status decided at the end
import { withTrailers } from "@danwo/proxy"

return withTrailers(
  new Response(messages, { headers: { "content-type": "application/grpc" } }),
  () => ({
    "grpc-status": failed ? "13" : "0",
  }),
)

What differs from a Node app

WhatPythonGo
the routing check at startdoesn't run: it lives in the Node preloaddoesn't run: it lives in the Node preload
calls to localhost, 127.0.0.1go through the proxy, unless your NO_PROXY names themnever proxied, so danwo can't fault or wait for them
time travelthe app keeps real timethe app keeps real time

Time travel moves the page's clock and the emulators, but not the app's own: danwo's clock shift lives in the Node preload. Calls from one Go app to another on loopback skip the proxy, so a matrix plans no faults on them and readiness doesn't wait for them.

A client that ignores the proxy variables, such as aiohttp without trust_env=True or a Go transport with Proxy: nil, calls the real hosts. unmatched: "block" can't stop it: its calls never reach the proxy.

The check at start

For a Node app, the preload checks that the process's calls reach the proxy before your code runs. That check runs inside Node, so it can't run in a Python or Go process, and nothing else confirms that one goes through the proxy. danwo says so once when the app starts, rather than reporting a pass or a failure:

stderr
danwo: api runs no Node process (uv, python), so danwo's check at start that its calls
reach the proxy, which runs inside Node, does not run for it, and nothing confirms its calls
go through the proxy: …

The other checks work as they do for Node. unmatched: "block" refuses at the proxy any host no emulator answers, and secrets: "fail" refuses a live-looking key in the app's environment.

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI