// Exercise 01 — Concurrent URL Health Checker // // ASSIGNMENT // ---------- // Implement CheckAll so that it: // // 1. Checks every URL in `urls` with an HTTP GET. // 2. Runs AT MOST `limit` checks concurrently (bounded concurrency). // 3. Uses a per-request timeout of 5s AND respects the global `ctx` // (if ctx is cancelled, pending/unstarted checks must return ctx.Err()). // 4. Returns one Result per URL, at the SAME index as the input URL. // 5. Reuses a single http.Client and properly drains+closes response bodies // so connections are reused. // // Run the tests: go test -race ./01-url-health-checker/ // Run the demo: go run ./01-url-health-checker/ package main import ( "context" "fmt" "io" "io/ioutil" "net/http" "sync" "time" ) type Result struct { URL string Status int Latency time.Duration Err error } // CheckAll checks all URLs with at most `limit` in flight at once. // TODO: implement me. func CheckAll(ctx context.Context, urls []string, limit int) []Result { results := make([]Result, len(urls)) // - semaphore (buffered channel) or worker pool for the concurrency limit sem := make(chan struct{}, limit) // - sync.WaitGroup to wait for completion var wg sync.WaitGroup // - http.NewRequestWithContext for cancellation client := &http.Client{Timeout: 5 * time.Second} for i, u := range urls { wg.Add(1) go func(i int, u string) { defer wg.Done() // wait for semaphore or ctx cancellation select { case sem <- struct{}{}: defer func() { <-sem }() case <-ctx.Done(): results[i] = Result{URL: u, Err: ctx.Err()} return } start := time.Now() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { results[i] = Result{URL: u, Err: err} return } resp, err := client.Do(req) if err != nil { results[i] = Result{URL: u, Err: err, Latency: time.Since(start)} return } defer resp.Body.Close() io.Copy(ioutil.Discard, resp.Body) results[i] = Result{URL: u, Err: err, Status: resp.StatusCode, Latency: time.Since(start)} }(i, u) } // - write results[i] from goroutine i (why is this safe without a mutex?) _ = ctx _ = limit wg.Wait() return results } func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Pick ONE of the url sets below (uncomment it, comment the others). // Tip: run with a bigger `limit` and compare total wall-clock time. // Option A — the original mix: OK, HTTP 500, DNS failure. urls := []string{"https://example.com", "https://httpbin.org/status/500", "https://nope.invalid"} //region Not-selected-options // Option B — all succeed (2xx). Good for measuring how `limit` affects // total time: with limit=1 it's the sum of latencies, with limit=8 ~max. // urls := []string{ // "https://example.com", // "https://www.google.com", // "https://www.cloudflare.com", // "https://github.com", // "https://go.dev", // "https://pkg.go.dev", // "https://httpbin.org/status/200", // "https://httpbin.org/get", // } // Option C — HTTP-level failures (connection succeeds, status is bad). // Err should be nil for these; the caller decides what a "healthy" status is. // urls := []string{ // "https://httpbin.org/status/200", // control // "https://httpbin.org/status/301", // redirect — client follows it by default, so you'll see the final status // "https://httpbin.org/status/401", // "https://httpbin.org/status/403", // "https://httpbin.org/status/404", // "https://httpbin.org/status/429", // "https://httpbin.org/status/500", // "https://httpbin.org/status/502", // "https://httpbin.org/status/503", // "https://example.com/definitely-not-here", // real site, 404 // } // Option D — network/transport failures (Err != nil, Status == 0). // Each fails for a DIFFERENT reason — print r.Err and read the message. // urls := []string{ // "https://example.com", // control // "https://nope.invalid", // DNS: .invalid TLD never resolves // "https://this-host-does-not-exist-12345.com", // DNS: NXDOMAIN // "http://localhost:1", // TCP: connection refused (nothing listens on port 1) // "http://10.255.255.1", // TCP: unroutable address → connect timeout (hits the 5s per-request timeout) // "https://expired.badssl.com", // TLS: expired certificate // "https://self-signed.badssl.com", // TLS: self-signed certificate // "https://wrong.host.badssl.com", // TLS: hostname mismatch // "https://untrusted-root.badssl.com", // TLS: unknown CA // "ftp://example.com", // unsupported protocol scheme // "not a url at all", // request creation fails (invalid URL) // } // Option E — slow responses: exercises the 5s per-request timeout AND the // global ctx. /delay/N sleeps N seconds server-side before replying. // With limit=2 and a 10s ctx, watch which ones finish, which time out // per-request, and which never start and return ctx.Err(). // urls := []string{ // "https://httpbin.org/delay/1", // OK // "https://httpbin.org/delay/3", // OK // "https://httpbin.org/delay/8", // exceeds 5s per-request timeout // "https://httpbin.org/delay/8", // exceeds 5s per-request timeout // "https://httpbin.org/delay/1", // may hit global ctx deadline before it even starts // "https://httpbin.org/delay/1", // "https://httpbin.org/delay/1", // } //endregion // Option F — everything at once, larger list. Set limit to 1, 4, 16 and // compare; check that the order of results still matches the input order. urls = []string{ "https://example.com", "https://go.dev", "https://httpbin.org/status/200", "https://httpbin.org/status/404", "https://httpbin.org/status/500", "https://httpbin.org/status/503", "https://httpbin.org/delay/2", "https://httpbin.org/delay/7", "https://nope.invalid", "http://localhost:1", "https://expired.badssl.com", "https://self-signed.badssl.com", "https://www.cloudflare.com", "https://github.com", } for _, r := range CheckAll(ctx, urls, 2) { fmt.Printf("%-40s status=%d latency=%v err=%v\n", r.URL, r.Status, r.Latency, r.Err) } }