Menu Close

Async HTTP Requests in Go

Sending HTTP requests to external services is a standard task for many applications written in Go. Since HTTP is a synchronous protocol the Implementation of sending HTTP Requests in the net/http package of the Go standard library is a blocking call in a program’s control flow. In this article, we’re looking at problems that could arise in applications if they use synchronous HTTP requests to reach out to multiple services dependencies. We’ll then dive into how to send HTTP requests asynchronously with Goroutines.

Synchronouse HTTP Requests

We’ll start out with a simple CLI application using synchronous requests to external dependencies to handle a pizza order. The CLI will use the data provided by flags to send requests to three external services in a microservice architecture. It calls the Order Service to store the order in the system, the Payment Service to process the payment, and the Store Service to notify a pizza store about the order:

async-http-request-go-services

💣: I’ll skip most error handling, for now, to keep the code simple, for production code you should test all errors returned by functions.

Let’s create the external services first:

package main

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

type Service struct {
	Name, Port   string
	ResponseTime int
}

func (service Service) HandleAllRequest(w http.ResponseWriter, r *http.Request) {
	time.Sleep(time.Duration(service.ResponseTime) * time.Millisecond)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(fmt.Sprintf("Response from %v\n", service.Name)))
}

func main() {

	var serviceName = flag.String("s", "", "Name of this Service")
	var port = flag.String("p", "", "HTTP port to listen to")
	var responseTime = flag.Int("r", 0, "Time in ms to wait before response")
	flag.Parse()

	service := Service{*serviceName, *port, *responseTime}
	http.HandleFunc("/", service.HandleAllRequest)
	go http.ListenAndServe(fmt.Sprintf(":%v", service.Port), nil)

	fmt.Printf("%v listening on port: %v, Press <Enter to exit>\n", service.Name, service.Port)
	fmt.Scanln()
}

With this source code, we can run all services the CLI depends on. It takes three flags -s the service name, -p the port the HTTP Server should listen to, and -r the time in milliseconds to wait before sending a response. If we send an HTTP Request to the service it will respond with “Response from: Name:”.

The CLI application calls the external services. This is the code for the CLI:

package main

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

type PizzaOrder struct {
	Pizza, Store, Price string
}

func main() {

	var pizza = flag.String("pizza", "", "Pizza to order")
	var store = flag.String("store", "", "Name of the Pizza Store")
	var price = flag.String("price", "", "Price")

	flag.Parse()

	order := PizzaOrder{*pizza, *store, *price}
	body, _ := json.Marshal(order)

	start := time.Now()
	// OrderService is expected at 8081
	orderResponse := SendPostRequest("http://localhost:8081", body)
	defer orderResponse.Body.Close()
	bytes, _ := ioutil.ReadAll(orderResponse.Body)
	fmt.Println(string(bytes))

	// PaymentService is expected at 8082
	paymentResponse := SendPostRequest("http://localhost:8082", body)
	defer paymentResponse.Body.Close()
	bytes, _ = ioutil.ReadAll(paymentResponse.Body)
	fmt.Println(string(bytes))

	storeResponse := SendPostRequest("http://localhost:8083", body)
	defer storeResponse.Body.Close()
	bytes, _ = ioutil.ReadAll(storeResponse.Body)
	fmt.Println(string(bytes))

	end := time.Now()

	fmt.Printf("Order processed after %v seconds\n", end.Sub(start).Seconds())
}

func SendPostRequest(url string, body []byte) *http.Response {
	response, err := http.Post(url, "application/json", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}

	return response
}

The CLI calls three Services with the order data provided by flags. It expects the services to be running at port 8081 to 8083. Start the services with the following command in different terminal sessions:

# For this first test the response time is 20ms (my current ping value)
go run server.go -s OrderService -p 8081 -r 20
go run server.go -s PaymentService -p 8082 -r 20
go run server.go -s StoreService -p 8083 -r 20

Now run the CLI and order some pizza:

go run main.go -pizza Calzone -store FPS -price 8.00
# Output:
# Response from OrderService
# Response from PaymentService
# Response from StoreService
# Order processed after 0.069001359 seconds

It takes around 70 milliseconds for the order to get processed. And this is what we’ve expected. The services are called sequentially and each of them adds its 20ms to the response time the remaining 10ms is the time it takes without the sleep calls.

Now imagine we’re using the CLI on a mobile connection with poor reception. We’ll simulate the behavior by setting 4 seconds as response time. Close the servers and start them again with the following commands:

go run server.go -s OrderService -p 8081 -r 4000
go run server.go -s PaymentService -p 8082 -r 4000
go run server.go -s StoreService -p 8083 -r 4000

Run the CLI again:

go run main.go -pizza Calzone -store FPS -price 8.00
# Output:
# Response from OrderService
# Response from PaymentService
# Response from StoreService
# Order processed after 12.01193376 seconds

If a transaction in an app is not approved after 10 seconds most users think it is broken.

The high response time leads to a problem for users with a poor internet connection, let’s dive into async requests to improve it.

Async HTTP Requests

The plan is to perform each request asynchronously by using Goroutines. Since Goroutines do not return values, we create a channel for each HTTP Request. Then we define the SendPostAsync function. It is just like the SendPostRequest method but instead of returning the response, it has an additional channel parameter. The function uses the channel to return the HTTP response.

package main

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

type PizzaOrder struct {
	Pizza, Store, Price string
}

func main() {

	var pizza = flag.String("pizza", "", "Pizza to order")
	var store = flag.String("store", "", "Name of the Pizza Store")
	var price = flag.String("price", "", "Price")

	flag.Parse()

	order := PizzaOrder{*pizza, *store, *price}
	body, _ := json.Marshal(order)

	start := time.Now()

	orderChan := make(chan *http.Response)
	paymentChan := make(chan *http.Response)
	storeChan := make(chan *http.Response)

	// OrderService is expected at 8081
	go SendPostAsync("http://localhost:8081", body, orderChan)

	// PaymentService is expected at 8082
	go SendPostAsync("http://localhost:8082", body, paymentChan)

	// StoreService is expected at 8083
	go SendPostAsync("http://localhost:8083", body, storeChan)

	orderResponse := <-orderChan
	defer orderResponse.Body.Close()
	bytes, _ := ioutil.ReadAll(orderResponse.Body)
	fmt.Println(string(bytes))

	paymentResponse := <-paymentChan
	defer paymentResponse.Body.Close()
	bytes, _ = ioutil.ReadAll(paymentResponse.Body)
	fmt.Println(string(bytes))

	storeResponse := <-storeChan
	defer storeResponse.Body.Close()
	bytes, _ = ioutil.ReadAll(storeResponse.Body)
	fmt.Println(string(bytes))

	end := time.Now()

	fmt.Printf("Order processed after %v seconds\n", end.Sub(start).Seconds())
}

func SendPostAsync(url string, body []byte, rc chan *http.Response) {
	response, err := http.Post(url, "application/json", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}

	rc <- response
}

func SendPostRequest(url string, body []byte) *http.Response {
	response, err := http.Post(url, "application/json", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}

	return response
}

Run it:

go run main.go -pizza Calzone -store FPS -price 8.00
# Output:
# Response from OrderService
# Response from PaymentService
# Response from StoreService
# Order processed after 4.01193386 seconds

Congrats the CLI can process the pizza order 3 times faster. But currently, the CLI responds to HTTP errors with a panic, which is not really appropriate for a failed request. We could use another channel for each request to publish its errors, but there is a more idiomatic way in Go to handle this.

Error Handling for Async HTTP Requests

We start by changing the SendPostAsync function to return the err instead of calling panic.

func SendPostAsync(url string, body []byte, rc chan *http.Response) error {
	response, err := http.Post(url, "application/json", bytes.NewReader(body))
	if err == nil {
		rc <- response
	}

	return err
}

Additionally, we change the channels to buffered channels, cause otherwise they will block at sending to the channel and never return nil or an error.

	orderChan := make(chan *http.Response, 1)
	paymentChan := make(chan *http.Response, 1)
	storeChan := make(chan *http.Response, 1)

Now we’ll use the errGroup and context package from the Go library. The errGrp.Go function takes a func() error, and runs it as a Goroutine. Within the closure, we call the SendPostAsync function and return its error value. After dispatching all requests, we can get the first error, if one exists, by calling errGrp.Wait().

You have to download errgroup with go get. It is in the Go library but it is stored in a sub repository. Note the path “golang.org/x/sync/errgroup”. A common misconception is that the “x” stands for experimental, but it stands for external.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"time"

	"golang.org/x/sync/errgroup"
)

type PizzaOrder struct {
	Pizza, Store, Price string
}

func main() {

	var pizza = flag.String("pizza", "", "Pizza to order")
	var store = flag.String("store", "", "Name of the Pizza Store")
	var price = flag.String("price", "", "Price")

	flag.Parse()

	order := PizzaOrder{*pizza, *store, *price}
	body, _ := json.Marshal(order)

	start := time.Now()

	orderChan := make(chan *http.Response, 1)
	paymentChan := make(chan *http.Response, 1)
	storeChan := make(chan *http.Response, 1)

	errGrp, _ := errgroup.WithContext(context.Background())

	// OrderService is expected at 8081
	errGrp.Go(func() error { return SendPostAsync("http://localhost:8081", body, orderChan) })

	// PaymentService is expected at 8082
	errGrp.Go(func() error { return SendPostAsync("http://localhost:8082", body, paymentChan) })

	// StoreService is expected at 8083
	errGrp.Go(func() error { return SendPostAsync("http://localhost:8083", body, storeChan) })

	err := errGrp.Wait()
	if err != nil {
		fmt.Println(err)
		fmt.Println("Error with submitting the order, try again later...")
		os.Exit(1)
	}

	orderResponse := <-orderChan
	defer orderResponse.Body.Close()
	bytes, _ := ioutil.ReadAll(orderResponse.Body)
	fmt.Println(string(bytes))

	paymentResponse := <-paymentChan
	defer paymentResponse.Body.Close()
	bytes, _ = ioutil.ReadAll(paymentResponse.Body)
	fmt.Println(string(bytes))

	storeResponse := <-storeChan
	defer storeResponse.Body.Close()
	bytes, _ = ioutil.ReadAll(storeResponse.Body)
	fmt.Println(string(bytes))

	end := time.Now()

	fmt.Printf("Order processed after %v seconds\n", end.Sub(start).Seconds())
}

func SendPostAsync(url string, body []byte, rc chan *http.Response) error {
	response, err := http.Post(url, "application/json", bytes.NewReader(body))
	if err == nil {
		rc <- response
	}

	return err
}

Now we have a great way to handle errors for the use-case of sending multiple HTTP requests at once and failing if one of them fails. But keep in mind, for easier use-case it might be sufficient to just return the error on another channel. The errgroup approach might be too much.

2 Comments

    • Johannes Malsam

      Hi Diego,

      thanks for the Feedback. Using the buildin requests functions like http.Post does unfortunatly not support setting headers. But you can use a http.Request to build more complex request and the http.DefaultClient to send those requests. I’ve written a small example in the Go Playground here is the link https://play.golang.org/p/HqBO_pc0ty6.

Leave a Reply

Your email address will not be published. Required fields are marked *