Give each service a /health endpoint and a background loop that calls the other one, then discover the problem that drives the rest of the course — neither server knows who is calling.
Learning objectives
Describe an HTTP call as a method plus a path, and a reply as a status plus a body.
Add a JSON /health endpoint to a Go server and a Node server.
Run a background polling loop alongside a server in both languages.
Show that an unauthenticated endpoint cannot distinguish its partner service from a stranger.
What we are adding
Two things, to each server:
a /health endpoint, which is a URL that answers "I am alive";
a background loop that calls the other server's /health every three seconds.
Flow· The link we are building
Go :8091 TypeScript :8092
│ │
│──── GET /health ────────────>│
│<─────────── GET /health ─────│
Why /health, of all things?
It looks like a toy. It is not.
Load balancers and container orchestrators call a /health endpoint on every service, constantly, to decide whether to keep sending it traffic. Almost every real service has one. It is the smallest realistic thing two services can say to each other, which makes it a perfect place to hang a security lesson.
The shape of an HTTP call
Every web call has the same four parts.
A request is a method plus a path:
requestHTTP
GET /health
A response is a status code plus a body:
responseHTTP
200 OK{"service":"go","status":"ok"}
The method says what kind of action you want. The path says which thing you want it done to. The status code says how it went.
The status codes you will actually use
Code
Means
Whose problem
200
Fine
—
401
I do not know who you are
The caller's
403
I know who you are, and no
The caller's
404
No such thing here
The caller's
500
I broke
Yours
401 and 403 confuse everyone exactly once. The short version:
Memory shortcut
401 asks "who are you?". 403 says "I know who you are, and you still cannot."
You will see a lot of 401 responses from Lesson 3 onward, because "who are you?" is the question this whole course is about.
Go: add the /health endpoint
New imports
Open main.go and extend the import block:
main.goGo
import ( "encoding/json" // NEW - to build JSON replies "fmt" "io" // NEW - to read the other server's reply "log" "net/http" "strings" // NEW - to tidy up logged text "time" // NEW - for the three-second wait)
Remember the peer's address
Change the single const port line into a block:
main.goGo
const ( port = "8091" peerURL = "http://127.0.0.1:8092/health")
Write the handler
A handler is a function that runs when someone visits a URL. In Go it always has the same shape: it receives w, which is what you write back, and r, which is what the caller sent.
main.goGo
// health answers anyone who asks. We have no way to know who that is.func health(w http.ResponseWriter, r *http.Request) { log.Printf("<- (unknown) GET /health") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "service": "go", "status": "ok", })}
Line by line:
log.Printf records that someone called us. We write (unknown) because we genuinely have no idea who it was. Remember that word — it is the villain of this lesson.
w.Header().Set(...) tells the caller "what follows is JSON".
json.NewEncoder(w).Encode(...) turns a Go map into JSON and writes it out.
The _ = in front of Encode is deliberate. Writing to w can fail if the caller disconnects mid-reply, but by then the response has already started and there is nothing useful left to do about it. Assigning to _ says "I saw this error and chose to ignore it" instead of quietly pretending it cannot happen.
That reads: when someone asks for /health, run the health function.
Test this much before adding more
Run go run . and, in another terminal:
zsh — 80×24
$curl http://127.0.0.1:8091/health
$
responseJSON
{"service":"go","status":"ok"}
Get this working before moving on. Build one thing, test it, then add the next thing. That habit saves enormous amounts of time, and it is the only debugging strategy that scales.
Go: call the other server
The polling loop
main.goGo
// poll calls the TypeScript server on a loop, so you can watch the link work.func poll() { client := &http.Client{Timeout: 3 * time.Second} for { time.Sleep(3 * time.Second) resp, err := client.Get(peerURL) if err != nil { // Normal until the other server is up. Keep trying. log.Printf("--> ts unreachable: %v", err) continue } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Printf("--> ts reply unreadable: %v", err) continue } log.Printf("--> ts %s %s", resp.Status, strings.TrimSpace(string(body))) }}
Three details are worth more than the rest of the function.
Always set a timeout. That is what Timeout: 3 * time.Second is for. Never make a network call without one. Without one, a server that accepts your connection and then goes silent can hang your program forever. This is one of the most common causes of a service that mysteriously stops doing anything.
Log the failure and keep going. The if err != nil { ... continue } branch means a failed call is retried on the next tick. We do not crash.
Close the body, always. Calling resp.Body.Close() is not optional. Forgetting leaks a connection every time, and it is the single most common Go beginner bug. We call it before checking the read error so it happens on both paths.
Start the loop alongside the server
Here is the problem: http.ListenAndServe never returns. So if you call poll() normally, either the server never starts or the loop never runs.
They need to run at the same time. In Go that is one word:
main.goGo
func main() { log.SetFlags(log.Ltime) mux := http.NewServeMux() mux.HandleFunc("/health", health) fmt.Printf("The Go Server is running on port %s\n", port) // `go` starts poll() alongside everything else, instead of waiting for it. go poll() log.Fatal(http.ListenAndServe("127.0.0.1:"+port, mux))}
go poll() means start this and carry on. Without the go, your program would sit inside the polling loop and never reach the line that starts the server.
The independent task that go starts is called a goroutine. Think of it as a second worker running the same program: it has its own place in the code, but it shares all the same variables. That sharing is convenient now and becomes something you have to be careful about in Lesson 10.
log.SetFlags(log.Ltime) makes log lines show only the time instead of the full date, which keeps the output narrow enough to read.
The complete Go file
main.goGo
package mainimport ( "encoding/json" "fmt" "io" "log" "net/http" "strings" "time")const ( port = "8091" peerURL = "http://127.0.0.1:8092/health")// health answers anyone who asks. We have no way to know who that is.func health(w http.ResponseWriter, r *http.Request) { log.Printf("<- (unknown) GET /health") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "service": "go", "status": "ok", })}// poll calls the TypeScript server on a loop, so you can watch the link work.func poll() { client := &http.Client{Timeout: 3 * time.Second} for { time.Sleep(3 * time.Second) resp, err := client.Get(peerURL) if err != nil { // Normal until the other server is up. Keep trying. log.Printf("--> ts unreachable: %v", err) continue } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Printf("--> ts reply unreadable: %v", err) continue } log.Printf("--> ts %s %s", resp.Status, strings.TrimSpace(string(body))) }}func main() { log.SetFlags(log.Ltime) mux := http.NewServeMux() mux.HandleFunc("/health", health) fmt.Printf("The Go Server is running on port %s\n", port) // `go` starts poll() alongside everything else, instead of waiting for it. go poll() log.Fatal(http.ListenAndServe("127.0.0.1:"+port, mux))}
TypeScript: the same two things
A peer address and a log helper
server.tsTypeScript
import { createServer } from "node:http";const PORT = 8092;const PEER_URL = "http://127.0.0.1:8091/health";function log(message: string) { console.log(new Date().toTimeString().slice(0, 8), message);}
toTimeString().slice(0, 8) gives you 17:04:22, which matches what Go's log.Ltime prints. Making both logs look alike is worth the two lines.
Handle /health
server.tsTypeScript
const server = createServer((req, res) => { if (req.url !== "/health") { res.writeHead(404); res.end(); return; } // We answer anyone who asks. We have no way to know who that is. log("<- (unknown) GET /health"); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ service: "typescript", status: "ok" }));});
Node has no built-in router, so you check req.url yourself.
Note the return after the 404. Leave it out and the code below runs as well, and you get a confusing Cannot set headers after they are sent crash. In Node, a reply is something you can accidentally send twice.
Polling from Node
server.tsTypeScript
async function poll() { while (true) { await new Promise((r) => setTimeout(r, 3000)); try { const response = await fetch(PEER_URL, { signal: AbortSignal.timeout(3000), }); log(`--> go ${response.status} ${await response.text()}`); } catch (error) { // Normal until the other server is up. Keep trying. log(`--> go unreachable: ${error}`); } }}
await new Promise((r) => setTimeout(r, 3000)) is how you say "wait three seconds" in JavaScript. It looks strange the first time and you will write it constantly.
That sleep controls when the next poll starts; it does not bound a request that has already started. AbortSignal.timeout(3000) gives this fetch its own three-second deadline, matching the Go client.
fetch is built into Node, so there is nothing to install. Lesson 6 is where we have to give it up.
Start it alongside the server
server.tsTypeScript
server.listen(PORT, "127.0.0.1", () => { console.log(`The TypeScript Server is running on port ${PORT}`); poll();});
Look carefully: poll() is called with noawait.
That is the TypeScript equivalent of Go's go poll(). Writing await poll() would wait forever, because the loop never ends.
The complete TypeScript file
server.tsTypeScript
import { createServer } from "node:http";const PORT = 8092;const PEER_URL = "http://127.0.0.1:8091/health";function log(message: string) { console.log(new Date().toTimeString().slice(0, 8), message);}const server = createServer((req, res) => { if (req.url !== "/health") { res.writeHead(404); res.end(); return; } // We answer anyone who asks. We have no way to know who that is. log("<- (unknown) GET /health"); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ service: "typescript", status: "ok" }));});// Call the Go server on a loop, so you can watch the link work.async function poll() { while (true) { await new Promise((r) => setTimeout(r, 3000)); try { const response = await fetch(PEER_URL, { signal: AbortSignal.timeout(3000), }); log(`--> go ${response.status} ${await response.text()}`); } catch (error) { // Normal until the other server is up. Keep trying. log(`--> go unreachable: ${error}`); } }}server.listen(PORT, "127.0.0.1", () => { console.log(`The TypeScript Server is running on port ${PORT}`); poll();});
Run both and watch
Terminal 1:
zsh — 80×24
$go run .
$
Terminal 2:
zsh — 80×24
$node server.ts
$
Within a few seconds, terminal 1 shows something like:
go output
The Go Server is running on port 8091
17:20:01 --> ts 200 OK {"service":"typescript","status":"ok"}
17:20:01 <- (unknown) GET /health
and terminal 2 shows:
typescript output
The TypeScript Server is running on port 8092
17:20:01 <- (unknown) GET /health
17:20:01 --> go 200 {"service":"go","status":"ok"}
--> is a call going out. <- is a call coming in. Both appear in both logs, so the two services are connected.
Break it on purpose
Press Ctrl+C on the TypeScript server and watch the Go log:
There is connection refused again, and now you know exactly what it means: nothing is listening.
The Go server keeps running. Start the TypeScript server again and the log heals by itself.
Design principle
A service that dies because a dependency blinked is worse than one that retries. Networks fail constantly — deploys, restarts, brief blips — and code that assumes every call succeeds will page someone at 3am.
Restore the TypeScript server before continuing.
The problem you just created
Both servers are running. Now run this from your own terminal:
zsh — 80×24
$curl http://127.0.0.1:8091/health
$
responseJSON
{"service":"go","status":"ok"}
You are not the TypeScript server, and it answered you anyway.
Look at what it logged:
go output
<- (unknown) GET /health
That (unknown) is an honest log line. The server cannot tell its partner service from you, from a script, or from anything else that can reach the port.
Anything on your computer can call these endpoints. If this were on a real network, anything on that network could too — and could read every byte, because plain HTTP is not encrypted.
Three problems, fixed one at a time
Problem
Where it gets fixed
Anyone can call
Lesson 3, badly. Lesson 7, properly
Anyone on the network can read the traffic
Lesson 6
No idea which user a call is for
Lessons 8 to 10
Everything from here is about replacing (unknown) with a name you can trust.
The other server is not running, or the ports are swapped
Go calls 8092; TypeScript calls 8091
Lesson 2 checkpoint
You are ready for Lesson 3 when you can explain all of these without rereading:
what the four parts of an HTTP call are;
the difference between 401 and 403;
why poll must run alongside the server rather than before it;
why every outbound call needs a timeout;
why (unknown) in the log is a real problem and not a cosmetic one.
Next you will do what almost everybody does first: give both services the same password and check it on every request. It works. Lesson 4 then takes it apart and shows why it is a poor foundation to build on.