Dev.to · 6 min read

Three copies of the same HTTP code, and how one Go engine fixed it

Three copies of the same HTTP code, and how one Go engine fixed it

A free API client for REST, GraphQL, gRPC and WebSocket, and what building it taught me about SSRF, sandboxing other people's JavaScript, and keeping secrets away from AI agents. It started with a login screen. I wanted to send one request to my own API, running on localhost, on my own machine. The API client I'd used for years asked me to sign in first. Then it wanted to sync my workspace. Then a banner told me that running a collection against a CSV file was a paid feature. I wasn't even angry, I was curious. How hard can an API client be? That was April 2025. The answer turned out to be "a lot harder than it looks", and also way more fun than I expected. The weekend project became Gostman, a free and open source (MIT) API client for REST, GraphQL, gRPC and WebSocket. It runs as a desktop app, in the browser and as a CLI, and there's no account, no telemetry in the app and no paid tier. This isn't a feature tour. It's the engineering story: one decision I got wrong, the rewrite that fixed it, and three problems that looked small and really weren't. If you only read one paragraph, read this one. Every request, from every surface (desktop, browser, CI, an AI agent), goes through one Go function. A small Policy value carries what's different about each environment. The public web proxy checks the IP it actually connects to, which closes the DNS rebinding hole that URL checks leave open. Scripts from imported collections run in the CLI inside a pure-Go JavaScript engine with time and memory limits. And secrets get matched by their value, including their common encodings, and replaced before anything reaches an AI agent. So yeah, why Go? An API client is mostly networking. Timeouts, redirects, TLS, proxies, cookies, cancellation, streaming. Go's standard library already has all of it, net/http, crypto/tls, context, a cookie jar, and none of it is magic. I also wanted gostman run to work in any CI pipeline without telling people to install Node.js first. Go gives you one static binary. Build it, copy it, done. For the desktop app I picked Wails. The backend is Go, the UI is React, and you don't ship a whole Chromium like Electron does. What I didn't appreciate at the time is that this meant the same Go code could sit behind a native window and behind a serverless function. That ended up being the entire story. THE ARCHITECTURE: Four ways in, one way out. The desktop app, the browser, a CI job and a coding agent all end up in the same function. It definitely didn't start like this though. Phase 1: A desktop app that sends requests Version one was pretty much what you'd guess. A Wails app with React on top. The UI builds a request object and hands it to Go, Go makes the HTTP call and sends back status, headers, body and timing. Everything was saved locally on your machine, no database and nothing in the cloud. (Much later, collections became plain YAML files in a folder, so you can commit them next to your code.) It worked, it was fast, and I was happy with it. Then people started asking for a version they could try without installing anything. Phase 2: The browser version, and my biggest mistake Which is fair. I wouldn't install a random app from a stranger on Reddit either. So I built a web version. A browser can't call any API you type in because of CORS, so requests from the web app go through a small Go proxy function on Vercel. This is where I messed up. The web frontend got its own JavaScript copy of the request preparation: variable substitution, query params, GraphQL bodies, auth headers, curl parsing. And the Go code that actually sent the request now existed twice too, once in the desktop backend and once in the Vercel proxy. Three copies of "basically the same code". It was not basically the same code. Copies of the same logic don't stay the same for long. Mine drifted slowly and quietly, and then all at once. Binary responses (images, PDFs, anything that isn't text) came back corrupted, because both Go copies did string(bodyBytes) on the response and handed the UI mangled text. Big integers changed value on the way out. Variables and GraphQL variables were decoded into floats, JSON.parse on the JavaScript side and json.Unmarshal into map[string]any on the Go side, and a 64-bit float can't hold every integer above 2^53. So a database ID like 9007199254740993 got sent as 9007199254740992. A different ID, with no error anywhere. That one was really fun to track down. And cookies set during a redirect just vanished. Neither copy had a cookie jar, so a Set-Cookie on the first hop was never sent to the second. Every bug I fixed in one copy was still sitting in another one. I was maintaining three API clients and telling myself it was one. Phase 3: One engine The fix was boring on purpose. There is now exactly one piece of code that builds and sends a request, a Go package called engine/, and from the outside it's one function: func Send(ctx context.Context, r Request, policy Policy) Response The desktop app calls it through a Wails binding. The Vercel proxy now just calls it too, and the JavaScript copy is gone. When the CLI and the MCP server came along, they called it as well. Nothing else builds its own http.Request. All the hard stuff lives behind that function: variable substitution, a dozen auth schemes (Basic, Bearer, OAuth 2 with PKCE, AWS SigV4, Digest, NTLM, Hawk and more), body encoding, redirects, the cookie jar, proxies and client certificates. The Policy struct The obvious question: if the desktop app and a public web proxy run the same code, how does the desktop app still reach localhost:3000 while the public proxy refuses to? Everything that's different between environments goes into one value: type Policy struct { BlockPrivate bool MaxResponseBytes int64 MaxTimeout time.Duration Permit func(host string) bool RevealSecrets bool MaxStreamDuration time.Duration RunCommands bool RawSockets bool ... } The desktop app passes a relaxed one. The hosted proxy passes this: var Policy = engine.Policy{ BlockPrivate: true, RecordBody: true, MaxResponseBytes: 3

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News