Build SitePulse in Go

Build a concurrent Go CLI that checks website status codes and response times.

Introduction

30 Second Summary

Opening one website to see whether it works takes seconds. A long list gets slower when one bad address interrupts the checks.

In this project, you will build SitePulse with Go. It reports each website's HTTP status and response time while continuing past a failed address.

What You'll Build

When SitePulse runs, you will see every URL reported as ONLINE with its status and elapsed time or as ERROR with the request failure.

By the end of this project, you'll have:

  • A multi-site status report that shows whether each URL is online. Each successful result includes its HTTP status and response time.
  • A concurrent checker that uses goroutines with a channel to collect one result per URL. Valid websites still finish when the malformed address fails.
  • A local automated test that verifies the HTTP logic without contacting a public website.
  • Secret Mission: Replace the hard-coded URL list with command-line arguments so SitePulse can check any websites you supply.

Are there any prerequisites?

No previous programming experience is required. You need internet access for the live website checks.

Before We Start

Before the hands-on work begins, this is your moment to define what SitePulse needs to achieve. A clear goal keeps every Go concept connected to a useful result.

Set Up SitePulse in WSL

SitePulse needs its editor to use the same Linux files as its terminal. Windows Subsystem for Linux (WSL) keeps those tools inside one environment.

In this step, you connect Visual Studio Code to WSL. You confirm Go 1.26.8 in that window before creating a runnable module.

In this step, get ready to:
  • Create the SitePulse workspace through WSL.
  • Verify the editor extensions inside the WSL environment.
  • Run a starter message from the new Go module.
Create the SitePulse workspace

A VS Code WSL window edits files stored inside Linux. Starting from a WSL terminal keeps the editor connected to the same files.

  • Press the Windows key to open the Windows search bar.
  • Type WSL into the search bar.
  • Press Enter to open the WSL terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

This command moves the WSL terminal into the Desktop directory. The SitePulse folder will be easy to locate after you create it.

  • Create the sitepulse folder and enter it by running these commands:
mkdir sitepulse
cd sitepulse

What do these commands do?

  • The first command creates the sitepulse folder for your project files.
  • The second command makes sitepulse the terminal's current directory.
  • Open the current folder in a VS Code WSL window by running this command:
code .

What does this command do?

The command opens the current sitepulse folder in VS Code through WSL. Commands from the integrated terminal now run inside the connected Linux environment.

You will see the sitepulse folder in the Explorer sidebar. The remote indicator in the bottom-left corner confirms that the window is connected to WSL.

VS Code opened without WSL?

Close the new VS Code window if its remote indicator does not mention WSL. Return to the WSL terminal before trying the folder-opening command again.

Still disconnected? Help me connect this VS Code window to WSL.

Verify the WSL extensions

VS Code separates extensions installed on Windows from extensions available inside a WSL workspace. SitePulse needs the WSL extension on the Windows side before the Go extension can work with the Linux toolchain.

  • Click the Extensions icon in the left Activity Bar.
  • Type WSL into the extensions search field.
  • Select the WSL extension from the search results.
  • Confirm the extension card shows that WSL is installed on Windows.

Why does WSL stay on Windows?

The WSL extension creates the bridge between the Windows editor and the Linux workspace. Workspace extensions can then run alongside the files inside WSL.

  • Clear the extensions search field.
  • Type Go into the extensions search field.
  • Select the Go extension from the search results.
  • Use the extension card's WSL install option if Go is missing from the connected environment.
  • Confirm the Go extension is listed for the connected WSL environment.

You have the editor bridge in place. The Go extension can now format and test code inside the same WSL environment.

Go extension missing from WSL?

Check the remote indicator before installing the Go extension. The extension card must target the connected WSL environment.

Need help with the extension location? Help me verify the Go extension inside WSL.

Initialize and run the Go module

The integrated terminal inherits the WSL connection from this VS Code window. You will use it to confirm the toolchain before creating the module files.

  • Click Terminal in the VS Code top menu.
  • Select New Terminal.
  • Confirm the terminal prompt points to the sitepulse folder.
  • Check the Go toolchain inside WSL by running this command:
go version

What does this command do?

This command prints the Go toolchain version available to the integrated WSL terminal. It confirms that VS Code can reach the expected installation.

You should see version information that includes go1.26.8.

Do you see a different Go version?

Confirm the terminal belongs to the WSL window if the output does not include go1.26.8. A Windows terminal can point to a different Go installation.

Still seeing the wrong version? Help me find the Go 1.26.8 toolchain inside WSL.

  • Initialize the SitePulse module by running this command:
go mod init example/sitepulse

What does this command create?

Go creates go.mod to identify the module as example/sitepulse. The generated language directive is go 1.25.0 under Go 1.26.8.

You will see go.mod appear in the Explorer sidebar. That file confirms the SitePulse module now exists.

  • Select the sitepulse folder in the Explorer sidebar.
  • Click the New File icon at the top of the Explorer sidebar.
  • Enter main.go in the file name box.
  • Press Enter to create the file.

The new main.go file opens in the editor. It also appears beside go.mod in the Explorer sidebar.

  • Add the SitePulse starter program by pasting this code into main.go:
package main

import "fmt"

func main() {
	fmt.Println("SitePulse ready")
}

What does this code do?

  • The package main declaration makes this file part of an executable program.
  • The fmt package provides formatted terminal output.
  • The main() function is the program's starting point.
  • The fmt.Println() call prints the starter message.
  • Save main.go by pressing Ctrl+S.

Before you run SitePulse, what message do you expect the terminal to print?

  • Compile and run the SitePulse module by running this command:
go run .

What does this command do?

Go compiles the main package in the current folder before running it. This checks that the module and starter code work together.

You will see SitePulse ready in the integrated WSL terminal.

That is your first SitePulse win. The Go module now runs from the same WSL environment that VS Code uses for editing.

Starter message missing?

  • Save main.go before running the module again.
  • Confirm the terminal prompt points to the sitepulse folder.
  • Compare the spelling and capitalization of SitePulse ready with the code above.

Still blocked? Help me debug the SitePulse starter program.

✔️ Awesome, I've got everything!

Great. Double check that go.mod and main.go are saved inside the sitepulse folder.

ⓧ I'd like to double check the full code

Compare both files with these complete versions. Every line should match.

module example/sitepulse

go 1.25.0

What should go.mod contain?

This file records the module path and the generated Go language directive. Both values come from the module initialization command.

package main

import "fmt"

func main() {
	fmt.Println("SitePulse ready")
}

What should main.go contain?

This file contains the complete runnable starter program. Its terminal output proves the module is ready for the website-checking logic.

Your SitePulse workspace now runs inside WSL. Next up, you will replace the starter message with the first live website status check.

Check Your First Website

Your SitePulse starter now runs inside Visual Studio Code on Windows Subsystem for Linux (WSL). The next goal is to replace its placeholder message with a live HTTP check.

Network requests can fail or wait too long. SitePulse will use a reusable client with a finite timeout before checking https://go.dev.

In this step, get ready to:
  • Configure a reusable HTTP client with a five-second timeout.
  • Request the official Go website with explicit failure handling.
  • Print the returned website status in the terminal.
Configure a reusable HTTP client

An HTTP client controls how SitePulse sends requests. A finite timeout prevents one unresponsive website from holding the checker forever.

  • In main.go, select the line import "fmt".
  • Replace the selected line with the import block plus shared client below:
import (
	"fmt"
	"net/http"
	"time"
)

var client = &http.Client{
	Timeout: 5 * time.Second,
}

What does this code do?

  • The net/http package supplies the client used to send website requests.
  • The time package supplies the duration used by the timeout.
  • The client variable stores one reusable client.
  • Later checks can share the same timeout configuration.
  • The Timeout: 5 * time.Second setting limits the full request lifecycle to five seconds.
  • Save main.go with Ctrl+S.
  • Confirm that the client configuration compiles by running this command in the integrated WSL terminal:
go run .

What does this check prove?

The go run . command compiles the current package before running it. A successful run confirms that the new imports plus client configuration are valid Go code.

You will see SitePulse ready again. This confirms that the reusable client compiles inside the existing program.

Good progress. Your starter program now has the request configuration needed for a live check.

Program does not compile?

  • Check that net/http sits inside the import parentheses.
  • Check that time sits inside the import parentheses.
  • Check that the client block sits between the import block and the existing main function.

Still stuck? Help me fix my SitePulse HTTP client configuration.

Check the website safely

A successful request returns a response that includes the website status. A failed request returns an error that SitePulse must handle before using that response.

Successful responses also contain a body that the caller closes after use. These branches keep the first live check safe from common request failures.

  • In main.go, select the entire existing main function.
  • Replace the selected function with the request workflow below:
func main() {
	url := "https://go.dev"
	resp, err := client.Get(url)
	if err != nil {
		fmt.Printf("ERROR  %-24s %v\n", url, err)
		return
	}
	defer resp.Body.Close()

	fmt.Printf("ONLINE %-24s %d %s\n", url, resp.StatusCode, http.StatusText(resp.StatusCode))
}

How does the request flow work?

  • The url variable holds the exact website address SitePulse checks.
  • The client.Get(url) call attempts the website request.
  • The returned resp value contains a successful response.
  • The returned err value describes a request failure.
  • The error branch prints an ERROR result before returning from main.
  • The defer resp.Body.Close() line schedules the successful response body to close when main finishes.
  • The final fmt.Printf call prints the URL with its numeric status code plus status text.
  • Save main.go with Ctrl+S.
Run the first live check

This check contacts a live website through your internet connection. The returned status code comes directly from the website response.

Before you run the check, do you expect SitePulse to print its starter message or an online status for the Go website?

  • Check the live website by running this command in the integrated WSL terminal:
go run .

What does this command prove?

The go run . command compiles and runs the current main package. The running program sends the request through the configured client.

You will see one line containing ONLINE plus https://go.dev. The same line includes the returned numeric status code plus its status text.

You have reached the first live SitePulse milestone. A real server response now becomes a readable terminal result.

Seeing an error result?

  • Confirm that your WSL environment can access the internet if SitePulse prints an ERROR line.
  • Check that the URL in main.go is exactly https://go.dev.
  • Run the check again after restoring the connection.

Need another pair of eyes? Help me diagnose my SitePulse website request.

✔️ Awesome, I've got everything!

Your SitePulse module now contains a reusable HTTP client plus a live website check. Your module files are ready for the checkpoint below.

ⓧ I'd like to double check the full code

Your go.mod file should still match this reference.

module example/sitepulse

go 1.25.0

What should match in go.mod?

The module path stays example/sitepulse. The language directive stays go 1.25.0.

Your complete main.go file should match this reference exactly.

package main

import (
	"fmt"
	"net/http"
	"time"
)

var client = &http.Client{
	Timeout: 5 * time.Second,
}

func main() {
	url := "https://go.dev"
	resp, err := client.Get(url)
	if err != nil {
		fmt.Printf("ERROR  %-24s %v\n", url, err)
		return
	}
	defer resp.Body.Close()

	fmt.Printf("ONLINE %-24s %d %s\n", url, resp.StatusCode, http.StatusText(resp.StatusCode))
}

What should match in main.go?

The file includes the shared five-second client configuration. The main function handles request failures before using the response.

A successful request closes its response body before the program finishes. The final line prints the website status in the terminal.

SitePulse can now check one real website. Next, you will expand the input to multiple URLs and observe how the current control flow behaves under pressure.

Watch One Bad URL Stop the Checker

Your first SitePulse check proved that Go can make an HTTP request. Now SitePulse needs to move through several URLs in one run.

A slice gives the program an ordered collection of URLs. This step keeps the current control flow in place so you can see how it behaves when one request fails.

In this step, get ready to:
  • Store three URL values in a slice.
  • Run the existing request block once for each URL.
  • Observe where the sequential checker stops after a request fails.
Inspect the single-URL flow

Your current main() function stores one URL before running one request. The next version keeps that request logic together while applying it to three values.

  • In main.go, locate the main() function.
  • Find the line url := "https://go.dev" that defines the current target.
Expand main to three URLs

The slice keeps the URLs in a deliberate order. A range loop then passes each value through the same request block.

  • In main.go, select the complete main() function.
  • Replace the selected function with the code below:
func main() {
	urls := []string{
		"https://go.dev",
		"not-a-url",
		"https://go.dev/doc/",
	}

	for _, url := range urls {
		resp, err := client.Get(url)
		if err != nil {
			fmt.Printf("ERROR  %-24s %v\n", url, err)
			return
		}
		defer resp.Body.Close()

		fmt.Printf("ONLINE %-24s %d %s\n", url, resp.StatusCode, http.StatusText(resp.StatusCode))
	}
}

How does the loop work?

  • The urls slice stores the three addresses in the order you want to check them.
  • The range loop assigns one slice entry to url during each iteration.
  • The client.Get(url) call reuses the existing client for every address.
  • The return statement preserves the current error exit inside main().
  • Save main.go.
Predict the stopping point

Before you run the checker, which URL do you expect to be the last one SitePulse reports?

  • Test the sequential checker by running:
go run .

You should see an ONLINE line for https://go.dev. An ERROR line for not-a-url comes next.

There is no line for https://go.dev/doc/.

You found the weak point in the sequential checker. The return statement exits main() as soon as the malformed URL produces an error.

Seeing a different result?

  • Check that not-a-url is the second value in the urls slice.
  • Confirm that return remains inside the if err != nil block.
  • Restore your internet connection if the first request fails before SitePulse reaches the malformed URL.

Still stuck? Help me debug why my SitePulse loop produces a different result.

✔️ Awesome, I've got everything!

Your main.go file is saved. The terminal proves that the malformed URL prevents the final valid URL from being checked.

ⓧ I'd like to double check the full code

Here is the complete main.go file for this checkpoint.

  • Compare it with your file line by line.
package main

import (
	"fmt"
	"net/http"
	"time"
)

var client = &http.Client{
	Timeout: 5 * time.Second,
}

func main() {
	urls := []string{
		"https://go.dev",
		"not-a-url",
		"https://go.dev/doc/",
	}

	for _, url := range urls {
		resp, err := client.Get(url)
		if err != nil {
			fmt.Printf("ERROR  %-24s %v\n", url, err)
			return
		}
		defer resp.Body.Close()

		fmt.Printf("ONLINE %-24s %d %s\n", url, resp.StatusCode, http.StatusText(resp.StatusCode))
	}
}

Your sequential checker now exposes exactly where one bad URL breaks the run. Next, you will give every URL an independent check and collect every outcome.

Keep Going with Goroutines and a Channel

Your sequential SitePulse checker exposed its weak point. The malformed URL ends the program before the final website gets checked.

Go can run each request inside its own goroutine. A channel gives those goroutines one path back to the program.

In this step, get ready to:
  • Return one result from every website check.
  • Format successful checks or failed checks through one function.
  • Launch every check concurrently without losing a result.

Why goroutines and a channel?

Each website check spends time waiting for a response. A goroutine lets another check progress during that wait.

The channel gives main one place to receive every outcome. SitePulse can report each check without depending on completion order.

Package each check as a result

A struct groups related values into one type. The result struct carries everything SitePulse needs to describe one completed check.

  • In main.go, place the result type above var client by copying this code:
type result struct {
	url        string
	statusCode int
	duration   time.Duration
	err        error
}

What does this type store?

  • url identifies the website that was checked.
  • statusCode stores the numeric HTTP response status.
  • duration records how long the request took as a time.Duration value.
  • err holds the request error when a check fails.
  • Save main.go.
  • Confirm the new type compiles by running this command:
go run .

What does this check prove?

The go run . command compiles the package before running it. Reaching the terminal output proves the new result declaration is valid Go code.

You should see the existing online result for https://go.dev. The malformed URL should produce the next error result.

Does the result type fail to compile?

  • Confirm that type result struct appears after the import block.
  • Check that every field remains inside the struct braces.
  • Compare the spelling of time.Duration with the code above.

Still stuck? Help me fix my Go result struct.

The checker also needs a reusable function that performs one request. Every path through that function sends exactly one result into the channel.

  • In main.go, place check between the client declaration and func main() by copying this code:
func check(url string, results chan<- result) {
	start := time.Now()
	resp, err := client.Get(url)
	elapsed := time.Since(start)

	if err != nil {
		results <- result{url: url, duration: elapsed, err: err}
		return
	}
	defer resp.Body.Close()

	results <- result{
		url:        url,
		statusCode: resp.StatusCode,
		duration:   elapsed,
	}
}

What does this function do?

  • time.Now() records when the check starts.
  • client.Get(url) performs the request with the reusable client.
  • time.Since(start) calculates the elapsed request time.
  • results <- result sends one failed or successful result back through the channel.
  • Save main.go.
  • Confirm the new function compiles by running this command:
go run .

What does this run confirm?

This run compiles check with the rest of the package. The terminal output confirms its parameter types match the new result definition.

You should reach the same first online result. The malformed URL should still end the current sequential loop.

Does the check function fail to compile?

  • Confirm that check sits outside main.
  • Check that the parameter uses chan<- result exactly.
  • Confirm that both result sends use the field names from the result struct.

Need another pair of eyes? Help me debug my Go check function.

Format every result consistently

The request function focuses on collecting data. The separate printResult function decides how each outcome appears in the terminal.

  • In main.go, place printResult between check and func main() by copying this code:
func printResult(r result) {
	if r.err != nil {
		fmt.Printf("ERROR  %-24s %v\n", r.url, r.err)
		return
	}

	fmt.Printf(
		"ONLINE %-24s %d %s (%v)\n",
		r.url,
		r.statusCode,
		http.StatusText(r.statusCode),
		r.duration.Round(time.Millisecond),
	)
}

How does the output work?

  • r.err selects the error format when a request failed.
  • http.StatusText(r.statusCode) adds the name associated with a known status code.
  • r.duration.Round(time.Millisecond) displays the elapsed time rounded to milliseconds.
  • fmt.Printf aligns each result into one terminal line.
  • Save main.go.
  • Confirm the output helper compiles by running this command:
go run .

What does this run confirm?

The command compiles the new formatting function. Reaching the current terminal output confirms every referenced result field exists.

You should still see the sequential checker reach the malformed URL. The next change connects these helpers to concurrent checks.

Does the output helper fail to compile?

  • Confirm that every field starts with r. inside printResult.
  • Check that time.Millisecond matches the existing time import.
  • Compare the parentheses around the multi-line fmt.Printf call with the code above.

Still blocked? Help me repair my printResult function.

Launch and collect every check

The final change replaces the fragile sequential loop. main starts one check per URL before receiving one result per URL.

  • Select the complete existing func main() block from its first line through its closing brace.
  • Replace the selected block by copying this code:
func main() {
	urls := []string{
		"https://go.dev",
		"not-a-url",
		"https://go.dev/doc/",
	}

	results := make(chan result)

	for _, url := range urls {
		go check(url, results)
	}

	for range urls {
		printResult(<-results)
	}
}

How does concurrency flow?

  • make(chan result) creates the channel used to carry completed checks.
  • go check(url, results) starts an independent check for each URL.
  • for range urls repeats the receive operation exactly once per URL.
  • printResult(<-results) receives the next completed result before printing it.
  • Save main.go.

Before you run this, do you expect the malformed URL to stop SitePulse or appear alongside the two successful checks?

  • Run the concurrent checker with this command:
go run .

What does the final run prove?

Each call to check sends one value into results. The receive loop waits for three values because urls contains three entries.

Completion order controls which result arrives first. The original slice order no longer controls the terminal order.

You should see exactly three result lines. They include https://go.dev, not-a-url, and https://go.dev/doc/.

That's the fragile path fixed: SitePulse now reports every check even when one request fails.

Seeing fewer than three results?

  • Confirm that the launch loop includes go check(url, results).
  • Confirm that the receive loop uses for range urls.
  • Check that both paths through check send a result into the channel.

Need help tracing the channel? Help me find why SitePulse prints fewer than three results.

✔️ Awesome, I've got everything!

Great. Double check that main.go is saved. Your latest run should contain exactly three result lines.

ⓧ I'd like to double check the full code

Here is the complete main.go for this step. Compare it with your file line by line.

package main

import (
	"fmt"
	"net/http"
	"time"
)

type result struct {
	url        string
	statusCode int
	duration   time.Duration
	err        error
}

var client = &http.Client{
	Timeout: 5 * time.Second,
}

func check(url string, results chan<- result) {
	start := time.Now()
	resp, err := client.Get(url)
	elapsed := time.Since(start)

	if err != nil {
		results <- result{url: url, duration: elapsed, err: err}
		return
	}
	defer resp.Body.Close()

	results <- result{
		url:        url,
		statusCode: resp.StatusCode,
		duration:   elapsed,
	}
}

func printResult(r result) {
	if r.err != nil {
		fmt.Printf("ERROR  %-24s %v\n", r.url, r.err)
		return
	}

	fmt.Printf(
		"ONLINE %-24s %d %s (%v)\n",
		r.url,
		r.statusCode,
		http.StatusText(r.statusCode),
		r.duration.Round(time.Millisecond),
	)
}

func main() {
	urls := []string{
		"https://go.dev",
		"not-a-url",
		"https://go.dev/doc/",
	}

	results := make(chan result)

	for _, url := range urls {
		go check(url, results)
	}

	for range urls {
		printResult(<-results)
	}
}

How to use this reference

This file combines the result type with the reusable request function. It also includes the output function plus both concurrent loops.

SitePulse now checks every URL independently while collecting each outcome in one place. Next up, you'll test the request logic against a local server so the result stays predictable without internet access.

Test the Checker Without the Internet

Your Go checker now runs three website checks concurrently. It keeps valid results moving when one URL fails.

Public websites can change without warning. Network access can also disappear.

A local test server gives your HTTP logic a fast test with a predictable response. This step adds an automated check that never contacts a public website.

In this step, get ready to:
  • Create a local test server that always returns status code 204.
  • Pass the server URL to check through a buffered channel.
  • Run the automated test to confirm the request logic passes locally.
Give SitePulse a local target

The net/http/httptest package creates a temporary local server for tests. You control its response inside main_test.go.

  • In the SitePulse file list in Visual Studio Code's left sidebar, use the file-creation control to create main_test.go.

You will see an empty main_test.go file open beside main.go.

  • Build the local server test by pasting this code into main_test.go:
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestCheckReturnsStatusCode(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}))
	defer server.Close()

	results := make(chan result, 1)
	check(server.URL, results)
	got := <-results

	if got.err != nil {
		t.Fatalf("check returned an unexpected error: %v", got.err)
	}
	if got.statusCode != http.StatusNoContent {
		t.Errorf("status code = %d; want %d", got.statusCode, http.StatusNoContent)
	}
}

What Does This Test Do?

  • The httptest.NewServer call hosts the handler on a local URL. The test never depends on a public website.
  • The handler responds with http.StatusNoContent. That gives check a fixed status to capture.
  • The make(chan result, 1) call creates a buffered channel. This lets check send one result before the test receives it.
  • The got.err value must stay empty. Any request failure stops the test.
  • The got.statusCode value must match http.StatusNoContent.
  • Save main_test.go in Visual Studio Code.
  • Confirm the file exists by checking the SitePulse file list in the left sidebar.

Good progress. You will see main_test.go beside main.go.

Test File Missing or Underlined?

  • Confirm the filename is exactly main_test.go.
  • Check that the first line is package main.
  • Compare each imported package with the code block above.

Still stuck? Help me check why my Go test file is not being recognized.

Run the automated test

The go test command compiles the package with its test files. It runs each matching test function without starting main.

Before you run this, make a prediction about whether check contacts the local test server or a public website.

  • Run the test from the integrated WSL terminal by running this command:
go test

What Does This Command Do?

The go test command finds files ending in _test.go. It runs TestCheckReturnsStatusCode against the local server.

You will see PASS in the terminal. The summary line starts with ok for example/sitepulse.

That is a solid finish. SitePulse now has a deterministic test for its request logic.

Test Not Passing?

  • Check that main_test.go ends with the required _test.go suffix.
  • Compare the package main line with the same line in main.go.
  • Confirm the local handler writes http.StatusNoContent.

Need another pair of eyes? Help me debug why the SitePulse automated test does not pass.

✔️ Awesome, I've got everything!

Your passing test confirms that check captures a predictable response from a local server.

ⓧ I'd like to double check the full code

  • Compare these three files with the files in your SitePulse folder.
module example/sitepulse

go 1.25.0
package main

import (
	"fmt"
	"net/http"
	"time"
)

type result struct {
	url        string
	statusCode int
	duration   time.Duration
	err        error
}

var client = &http.Client{
	Timeout: 5 * time.Second,
}

func check(url string, results chan<- result) {
	start := time.Now()
	resp, err := client.Get(url)
	elapsed := time.Since(start)

	if err != nil {
		results <- result{url: url, duration: elapsed, err: err}
		return
	}
	defer resp.Body.Close()

	results <- result{
		url:        url,
		statusCode: resp.StatusCode,
		duration:   elapsed,
	}
}

func printResult(r result) {
	if r.err != nil {
		fmt.Printf("ERROR  %-24s %v\n", r.url, r.err)
		return
	}

	fmt.Printf(
		"ONLINE %-24s %d %s (%v)\n",
		r.url,
		r.statusCode,
		http.StatusText(r.statusCode),
		r.duration.Round(time.Millisecond),
	)
}

func main() {
	urls := []string{
		"https://go.dev",
		"not-a-url",
		"https://go.dev/doc/",
	}

	results := make(chan result)

	for _, url := range urls {
		go check(url, results)
	}

	for range urls {
		printResult(<-results)
	}
}
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestCheckReturnsStatusCode(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	}))
	defer server.Close()

	results := make(chan result, 1)
	check(server.URL, results)
	got := <-results

	if got.err != nil {
		t.Fatalf("check returned an unexpected error: %v", got.err)
	}
	if got.statusCode != http.StatusNoContent {
		t.Errorf("status code = %d; want %d", got.statusCode, http.StatusNoContent)
	}
}

Secret mission

Accept URLs from the Command Line

SitePulse currently checks the same three websites every time it runs. Turn it into a reusable tool that accepts any set of URLs from the command line and guides the user when no URLs are supplied.

Clean Up Your Resources

Clean Up Your Resources

SitePulse runs entirely in your WSL environment with no ongoing costs. Your options are to keep the project available, pause your work, or delete the local files.

Resources you used:

  • The sitepulse folder in WSL containing go.mod, main.go, and main_test.go.

Keep everything running

No action is needed. Choose this option if you want to keep testing websites or extending SitePulse.

  • Keep the sitepulse folder in your WSL environment.
  • Return to the integrated WSL terminal whenever you want to check more URLs.
  • Keep main_test.go as a repeatable check for future code changes.

Pause - I'll come back to this later

SitePulse exits after each run, so there is no server to stop. Pausing means closing your current WSL editing session while keeping every project file.

  • Close the integrated terminal panel.
  • Close the editor window connected to WSL.
  • Keep the sitepulse folder unchanged for your next session.

Delete - I don't want to use this again

Delete the local project only if you no longer want the SitePulse source files. Your development tools remain untouched.

  • Move out of the sitepulse folder and delete it by running these commands in the integrated WSL terminal:
cd ..
rm -rf sitepulse

What Does This Cleanup Do?

  • The first line moves the terminal to the directory that contains sitepulse.
  • The second line permanently removes the sitepulse folder with every file inside it.
  • Check your editor's file list for go.mod, main.go, and main_test.go.

You should no longer see the SitePulse files. The local project has now been removed.

Still See the Project Files?

  • Confirm that the terminal prompt no longer points inside sitepulse before retrying the cleanup command.
  • Close any process using a file in sitepulse if the terminal reports a permission problem.
  • Get help removing the SitePulse folder.

Nice Work!

Nice Work!

You did it! SitePulse now checks supplied URLs concurrently while continuing past request errors.

You've learned how to:

  • Build a SitePulse CLI in Go that makes reusable HTTP requests. It reports each URL with a status code plus elapsed time.
  • Use error handling to keep one malformed URL from stopping valid checks. Goroutines run the checks independently. A channel collects one result per URL.
  • Create a deterministic automated test with httptest.NewServer. The local server verifies the HTTP status logic without relying on a public website.
  • Secret Mission: Replaced hard-coded URLs with command-line arguments. SitePulse now accepts any supplied URL list. It prints a usage message when the list is empty.

Ready to quiz yourself?