Lesson 2 of 15 · 30 min
Make the Two Servers Talk
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
/healthendpoint, which is a URL that answers "I am alive"; - a background loop that calls the other server's
/healthevery three seconds.
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:
GET /healthA response is a status code plus a body:
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:
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:
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.
// 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.Printfrecords 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.
Connect the handler to a URL
In main, the mux is no longer empty:
mux := http.NewServeMux()
mux.HandleFunc("/health", health)That reads: when someone asks for /health, run the health function.
Test this much before adding more
Run go run . and, in another terminal:
{"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
// 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:
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
package main
import (
"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
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
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
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.listen(PORT, "127.0.0.1", () => {
console.log(`The TypeScript Server is running on port ${PORT}`);
poll();
});Look carefully: poll() is called with no await.
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
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:
Terminal 2:
Within a few seconds, terminal 1 shows something like:
The Go Server is running on port 8091
17:20:01 --> ts 200 OK {"service":"typescript","status":"ok"}
17:20:01 <- (unknown) GET /healthand terminal 2 shows:
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:
--> ts unreachable: dial tcp 127.0.0.1:8092: connect: connection refusedThere 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:
{"service":"go","status":"ok"}You are not the TypeScript server, and it answered you anyway.
Look at what it logged:
<- (unknown) GET /healthThat (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.
Your repository so far
secure-services/
├── go.mod
├── main.go
└── server.tsCommon errors
| Error | Meaning | Fix |
|---|---|---|
Cannot set headers after they are sent | You replied twice | Add return after res.end() in the 404 branch |
| Go starts but never polls | You wrote poll() instead of go poll() | Add the go |
| Node logs the startup line but never serves | You wrote await poll() | Remove the await |
unreachable: connection refused forever | 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
401and403; - why
pollmust 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.