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.
{
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:
| Variable | Value | Read by |
|---|---|---|
HTTPS_PROXY, https_proxy | the proxy, http://127.0.0.1:<port> | almost every client; curl wants lower case |
HTTP_PROXY, http_proxy | the same | clients making plain HTTP calls |
NO_PROXY, no_proxy | your own NO_PROXY, then the proxy's own | every client that reads the two above |
SSL_CERT_FILE | the CA bundle | OpenSSL: Python's ssl, httpx, Ruby, PHP, Go on Linux |
REQUESTS_CA_BUNDLE | the CA bundle | Python's requests, which ignores SSL_CERT_FILE |
CURL_CA_BUNDLE | the CA bundle | curl, and requests when the one above is unset |
GRPC_DEFAULT_SSL_ROOTS_FILE_PATH | the CA bundle | gRPC in Python, Ruby and PHP, and grpc-js in Node |
NODE_EXTRA_CA_CERTS | danwo's CA alone | Node, 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
| Client | Goes through the proxy | Trusts danwo's CA |
|---|---|---|
| requests | yes | yes, through REQUESTS_CA_BUNDLE |
| httpx (and the OpenAI and Anthropic SDKs) | yes | yes, through SSL_CERT_FILE |
urllib.request | yes | yes, through SSL_CERT_FILE |
| aiohttp | only with trust_env=True | yes, through SSL_CERT_FILE |
| the Resend SDK | yes, it uses requests | yes |
| the Stripe SDK | yes | no: 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:
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_bundleIn 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:
{
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:
| Call | Recorded as | Faulted by the matrix |
|---|---|---|
| the site's server calling the API | server, passed on | no: it is your own app |
| the browser calling the API | browser, passed on | with pageCalls: true |
| the API calling Stripe or Resend | server, routed | yes |
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:
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:
| SDK | Where the client goes |
|---|---|
| stripe-go | stripe.NewBackendsWithConfig(&stripe.BackendConfig{HTTPClient: client}) |
| resend-go | resend.NewCustomClient(client, apiKey) |
| your own calls | client.Do(req) |
Start it from the config
Build once, then run the binary. danwo passes the port as PORT:
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:
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:
| Library | Goes through the proxy | Trusts danwo's CA |
|---|---|---|
| grpc-js (Node; Google Cloud's Node libraries) | yes, through https_proxy | yes, through NODE_EXTRA_CA_CERTS |
| grpcio (Python; Google Cloud's Python ones) | yes, through https_proxy | yes, through GRPC_DEFAULT_SSL_ROOTS_FILE_PATH |
| grpc-go | yes, through HTTPS_PROXY | on 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:
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:
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
resetresets 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-statusin its headers, or decide it at the end withwithTrailers, below. - If the real server closes a connection just as a call goes out, danwo sends the call again on a fresh connection.
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
| What | Python | Go |
|---|---|---|
| the routing check at start | doesn't run: it lives in the Node preload | doesn't run: it lives in the Node preload |
calls to localhost, 127.0.0.1 | go through the proxy, unless your NO_PROXY names them | never proxied, so danwo can't fault or wait for them |
| time travel | the app keeps real time | the 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:
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.