Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 36

Making HTTP Requests with `http.Client`

Recommended resources

Hello! Welcome to the sixth module of our course, "Interacting with Web Services and APIs."

In the last module, we built a concurrent worker pool, a powerful pattern for running I/O-bound tasks in parallel. One of the most common I/O-bound tasks in DevOps is interacting with web services and REST APIs—from cloud providers like AWS, to CI/CD platforms like GitLab, to monitoring systems like Prometheus.

This lesson kicks off our journey into networking by focusing on the fundamental building block: making HTTP requests. You will learn how to use Go's standard net/http package to build a client that can fetch data with GET requests and send data with POST requests. Mastering this is the first step toward building robust automation tools that can communicate with any HTTP-based service.

1. The Foundation: A Robust HTTP Client

Go's net/http package provides everything you need to work with HTTP. A central type is the http.Client, which is responsible for sending requests and managing state like cookies and persistent connections.

While you can make simple requests with package-level functions like http.Get(), it is a strong best practice to create your own http.Client instance. Why? The default client used by these helper functions has no timeout. This means a request to an unresponsive server could cause your program to hang indefinitely, which is a significant risk for any automation script.

Creating a custom client is straightforward and allows you to configure crucial behavior, most importantly, a request timeout.

Making HTTP Requests with the HTTP Client in Go

The video "Making HTTP Requests with the HTTP Client in Go" by Code & Learn starts by explaining exactly why you should create a custom client. Let's start there.

Watch from the beginning to 01:41 to understand the motivation behind using a custom client. Then, skip to 02:48 and watch until 03:22 to see how a custom http.Client is instantiated with a specific Timeout.

The key takeaway is this piece of code:

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

This simple configuration ensures that no single HTTP request will block your application for more than 10 seconds.


2. Fetching Data with GET Requests

A GET request is used to retrieve data from a server. This is what your browser does when you visit a webpage, and it's what your code will do to fetch the status of a service or pull configuration from an API endpoint.

The process involves a few clear steps:

  1. Create the Request: Construct an http.Request object specifying the method ("GET"), the URL, and no body. Using http.NewRequest gives you more flexibility than the simpler http.Get helper.
  2. Execute the Request: Use your custom client's Do() method to send the request.
  3. Check for Errors: The Do() method returns a response and an error. Always check the error first (e.g., for network issues).
  4. Close the Response Body: The response body is an io.ReadCloser that must be closed to release the underlying network connection. The defer statement is perfect for this: defer resp.Body.Close().
  5. Check the Status Code: A successful network round-trip doesn't mean the request was successful. You must check the HTTP status code (e.g., resp.StatusCode != http.StatusOK).
  6. Process the Body: If the status is OK, you can read the response body, for example, by decoding it from JSON into a Go struct.

Making HTTP Requests with the HTTP Client in Go

Let's continue with the Code & Learn video, which provides a complete walkthrough of making a GET request to a public API, decoding the JSON response, and handling status codes.

Watch from 03:22 to 05:27 and then from 06:44 to 08:15. This covers the entire lifecycle of a GET request: (03:22) Creating the request with http.NewRequestWithContext. (04:10) Executing with client.Do() and using defer resp.Body.Close(). (04:47) Decoding the JSON response body. (06:44) Checking the resp.StatusCode for non-2xx responses, which is a crucial error handling step.

Here's a summary of the code structure for a GET request:

package main

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

func main() {
	// 1. Create a custom client with a timeout
	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	// 2. Create the request
	req, err := http.NewRequest("GET", "https://httpbin.org/get", nil)
	if err != nil {
		fmt.Printf("Error creating request: %s\n", err)
		os.Exit(1)
	}

	// 3. Execute the request
	resp, err := client.Do(req)
	if err != nil {
		fmt.Printf("Error making http request: %s\n", err)
		os.Exit(1)
	}
	// 4. IMPORTANT: Defer closing the body to prevent resource leaks
	defer resp.Body.Close()

	// 5. Check the status code
	if resp.StatusCode != http.StatusOK {
		fmt.Printf("Error: received status code %d\n", resp.StatusCode)
		os.Exit(1)
	}
	
	fmt.Println("Client: got response!")
	fmt.Printf("Client: status code: %d\n", resp.StatusCode)

	// 6. Read and print the body
	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Printf("Error reading response body: %s\n", err)
		os.Exit(1)
	}
	
	fmt.Printf("Client: response body: %s\n", string(bodyBytes))
}

3. Sending Data with POST Requests

A POST request is used to send data to a server, typically to create or update a resource. For example, you might POST a JSON payload to a GitLab API endpoint to trigger a new CI/CD pipeline.

The process is very similar to a GET request, with two key additions:

  1. Provide a Request Body: The data you send must be an io.Reader. For JSON, this usually means marshaling a Go struct into a []byte slice and then wrapping it with a bytes.NewReader or bytes.NewBuffer.
  2. Set the Content-Type Header: You must tell the server what kind of data you're sending. For JSON, this header's value is "application/json".

How To Make HTTP Requests in Go

The article "How To Make HTTP Requests in Go" from DigitalOcean provides excellent, step-by-step instructions for constructing and sending a POST request.

Please read two sections from this article: "Sending a POST Request": Focus on how a []byte slice containing JSON is wrapped in a bytes.NewReader to create an io.Reader for the request body. Note how this reader is passed to http.NewRequest. "Customizing an HTTP Request": Pay close attention to the code snippet showing how to set the Content-Type header using req.Header.Set("Content-Type", "application/json"). This is a critical and often-forgotten step. You can ignore the parts about setting up a local server; the core concepts of building the client-side request are what matter here.

Combining these concepts, the structure for a POST request with a JSON body looks like this:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

// A struct to model our JSON data
type JobPayload struct {
	Name   string `json:"name"`
	Branch string `json:"branch"`
}

func main() {
	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	// 1. Create the data and marshal it to JSON
	payload := JobPayload{Name: "deploy-production", Branch: "main"}
	payloadBytes, err := json.Marshal(payload)
	if err != nil {
		// handle error
	}
	// Create an io.Reader from the JSON byte slice
	bodyReader := bytes.NewReader(payloadBytes)

	// 2. Create the request, now with a method and body
	req, err := http.NewRequest("POST", "https://httpbin.org/post", bodyReader)
	if err != nil {
		// handle error
	}

	// 3. Set the crucial Content-Type header
	req.Header.Set("Content-Type", "application/json")

	// Execute the request and process the response (same as GET)
	resp, err := client.Do(req)
	if err != nil {
		// handle error
	}
	defer resp.Body.Close()

	fmt.Printf("Status: %d\n", resp.StatusCode)
	
	respBody, _ := io.ReadAll(resp.Body)
	fmt.Printf("Response Body: %s\n", string(respBody))
}
Test your understanding!

You need to write a function that reports the status of a deployment to a monitoring service. The function should send an HTTP POST request to the URL https://example.com/api/report.

The request must contain a JSON body with the following structure:

{
  "service": "api-gateway",
  "status": "success"
}

Your task is to complete the reportStatus function below. You don't need to write the main function or handle the response, just construct and send the request correctly.

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "time"
)

type StatusReport struct {
    Service string `json:"service"`
    Status  string `json:"status"`
}

// reportStatus sends a deployment status to a monitoring service.
func reportStatus(serviceName, status string) (*http.Response, error) {
    client := &http.Client{
        Timeout: 5 * time.Second,
    }

    // --- YOUR CODE GOES HERE ---
    // 1. Create a StatusReport instance with the provided arguments.
    // 2. Marshal it into JSON bytes.
    // 3. Create an http.Request with the "POST" method, URL, and JSON body.
    // 4. Set the "Content-Type" header to "application/json".
    // 5. Execute the request with client.Do() and return its response and error.
    // --- END OF YOUR CODE ---
}
Show answer

Here's the completed reportStatus function:

// reportStatus sends a deployment status to a monitoring service.
func reportStatus(serviceName, status string) (*http.Response, error) {
	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	// 1. Create a StatusReport instance.
	report := StatusReport{
		Service: serviceName,
		Status:  status,
	}

	// 2. Marshal it into JSON bytes.
	jsonData, err := json.Marshal(report)
	if err != nil {
		return nil, err
	}

	// 3. Create an http.Request with the "POST" method, URL, and JSON body.
	req, err := http.NewRequest("POST", "https://example.com/api/report", bytes.NewBuffer(jsonData))
	if err != nil {
		return nil, err
	}

	// 4. Set the "Content-Type" header.
	req.Header.Set("Content-Type", "application/json")

	// 5. Execute the request and return the result.
	return client.Do(req)
}

This solution correctly follows all the steps required for a POST request: creating and marshaling the data, wrapping it in an io.Reader (bytes.NewBuffer works just like bytes.NewReader here), creating the request, setting the header, and finally executing it.


Conclusion

In this lesson, you've learned the essential mechanics of interacting with HTTP services in Go. This skill is a cornerstone for building DevOps automation, allowing your tools to communicate with the vast ecosystem of web-based APIs.

Key Takeaways:

  • Always create a custom http.Client to set a Timeout and avoid hanging requests.
  • Use http.NewRequest and a client's Do() method to have full control over the request.
  • For GET requests, the process is: create request, execute, check errors and status, defer body closure, and read the body.
  • For POST requests, you must also provide an io.Reader as the request body and set the Content-Type header appropriately.
  • Always remember defer resp.Body.Close() to prevent resource leaks.

Next Up

We've seen how to get a response body, but often that body is complex JSON. In our next lesson, we will dive deeper into parsing JSON data from API responses into Go structs. You'll learn how to handle nested objects, arrays, and fields with different names, enabling you to work with real-world API data effectively.