Skip to content

Commit 18655f8

Browse files
committed
Implementation of output and error handling
1 parent 2244e7e commit 18655f8

File tree

6 files changed

+325
-0
lines changed

6 files changed

+325
-0
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package fetcher
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"strconv"
10+
"time"
11+
)
12+
13+
var ErrRetry = errors.New("should retry")
14+
15+
type WeatherFetcher struct {
16+
client http.Client
17+
}
18+
19+
// Fetch gets the weather. If it encounters an error, it will either return retryError if the error is retriable, or another error if it is fatal.
20+
func (w *WeatherFetcher) Fetch(url string) (string, error) {
21+
response, err := w.client.Get(url)
22+
if err != nil {
23+
// Add context to the error about what we were trying to do when we encountered it.
24+
// We don't wrap with something like "couldn't get weather", because our caller is expected to add that kind of context.
25+
return "", fmt.Errorf("couldn't make HTTP request: %w", err)
26+
}
27+
defer response.Body.Close()
28+
switch response.StatusCode {
29+
case http.StatusOK:
30+
body, err := io.ReadAll(response.Body)
31+
if err != nil {
32+
return "", fmt.Errorf("error trying to read response: %w", err)
33+
}
34+
return string(body), nil
35+
case http.StatusTooManyRequests:
36+
if err := handle429(response.Header.Get("retry-after")); err != nil {
37+
return "", fmt.Errorf("error handling 'too many requests' response: %w", err)
38+
}
39+
return "", ErrRetry
40+
default:
41+
errorDescription := convertHTTPErrorResponseToDescription(response)
42+
return "", fmt.Errorf("unexpected response from server: %s", errorDescription)
43+
}
44+
}
45+
46+
func handle429(retryAfterHeader string) error {
47+
delay, err := parseDelay(retryAfterHeader)
48+
if err != nil {
49+
// handle429 is a really small function that doesn't really do much - its job is "parse a header to seconds, then sleep for that many seconds".
50+
// Accordingly, we don't really have much context to add to the error here, so we won't wrap it.
51+
return err
52+
}
53+
// This code considers each request independently - it would also be very reasonable to keep a timer since when we made the first request, and give up if the _total_ time is going to be more than 5 seconds, rather than the per-request time.
54+
if delay > 5*time.Second {
55+
return fmt.Errorf("giving up request: server told us it's going to be too busy for requests for more than the next 5 seconds")
56+
}
57+
if delay > 1*time.Second {
58+
fmt.Fprintf(os.Stderr, "Server reported it's receiving too many requests - waiting %s before retrying\n", delay)
59+
}
60+
time.Sleep(delay)
61+
return nil
62+
}
63+
64+
func parseDelay(retryAfterHeader string) (time.Duration, error) {
65+
// Try to decode the retry-after header as a whole number of seconds.
66+
if waitFor, err := strconv.Atoi(retryAfterHeader); err == nil {
67+
return time.Duration(waitFor) / time.Nanosecond * time.Second, nil
68+
}
69+
// If it wasn't a whole number of seconds, maybe it was a timestamp - try to decode that.
70+
if waitUntil, err := http.ParseTime(retryAfterHeader); err == nil {
71+
return time.Until(waitUntil), nil
72+
}
73+
// If we couldn't parse either of the expected forms of the header, give up.
74+
// Include the raw value in the error to help with debugging.
75+
// Note that if this were a web service, we'd probably log the bad value on the server-side, but not return as much information to the user.
76+
return -1, fmt.Errorf("couldn't parse retry-after header as an integer number of seconds or a date. Value was: %q", retryAfterHeader)
77+
}
78+
79+
func convertHTTPErrorResponseToDescription(response *http.Response) string {
80+
var bodyString string
81+
body, err := io.ReadAll(response.Body)
82+
if err == nil {
83+
bodyString = string(body)
84+
} else {
85+
bodyString = "<error reading body>"
86+
}
87+
return fmt.Sprintf("Status code: %s, Body: %s", response.Status, bodyString)
88+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package fetcher
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"testing"
7+
"time"
8+
9+
"github.com/CodeYourFuture/immersive-go-course/projects/output-and-error-handling/testutils"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestFetch200(t *testing.T) {
14+
transport := testutils.NewMockRoundTripper(t)
15+
transport.StubResponse(http.StatusOK, "some weather", nil)
16+
defer transport.AssertGotRightCalls()
17+
18+
f := &WeatherFetcher{client: http.Client{
19+
Transport: transport,
20+
}}
21+
22+
weather, err := f.Fetch("http://doesnotexist.com/")
23+
24+
require.NoError(t, err)
25+
require.Equal(t, "some weather", weather)
26+
}
27+
28+
func TestFetch429(t *testing.T) {
29+
transport := testutils.NewMockRoundTripper(t)
30+
headers := make(http.Header)
31+
headers.Set("retry-after", "1")
32+
transport.StubResponse(http.StatusTooManyRequests, "server overloaded", headers)
33+
defer transport.AssertGotRightCalls()
34+
35+
f := &WeatherFetcher{client: http.Client{
36+
Transport: transport,
37+
}}
38+
39+
start := time.Now()
40+
_, err := f.Fetch("http://doesnotexist.com/")
41+
elapsed := time.Since(start)
42+
43+
require.Equal(t, ErrRetry, err)
44+
require.GreaterOrEqual(t, elapsed, 1*time.Second)
45+
}
46+
47+
func Test500(t *testing.T) {
48+
transport := testutils.NewMockRoundTripper(t)
49+
transport.StubResponse(http.StatusInternalServerError, "Something went wrong", nil)
50+
defer transport.AssertGotRightCalls()
51+
52+
f := &WeatherFetcher{client: http.Client{
53+
Transport: transport,
54+
}}
55+
56+
_, err := f.Fetch("http://doesnotexist.com/")
57+
58+
require.EqualError(t, err, "unexpected response from server: Status code: 500 Internal Server Error, Body: Something went wrong")
59+
}
60+
61+
func TestDisconnect(t *testing.T) {
62+
transport := testutils.NewMockRoundTripper(t)
63+
transport.StubErrorResponse(fmt.Errorf("connection failed"))
64+
defer transport.AssertGotRightCalls()
65+
66+
f := &WeatherFetcher{client: http.Client{
67+
Transport: transport,
68+
}}
69+
70+
_, err := f.Fetch("http://doesnotexist.com/")
71+
72+
require.EqualError(t, err, "couldn't make HTTP request: Get \"http://doesnotexist.com/\": connection failed")
73+
}
74+
75+
func TestParseDelay(t *testing.T) {
76+
// Generally when testing time, we'd inject a controllable clock rather than really using time.Now().
77+
futureTime := time.Date(2051, time.February, 1, 14, 00, 01, 0, time.UTC)
78+
futureTimeString := "Wed, 01 Feb 2051 14:00:01 GMT"
79+
80+
for name, tc := range map[string]struct {
81+
header string
82+
delay time.Duration
83+
err string
84+
}{
85+
"integer seconds": {
86+
header: "10",
87+
delay: 10 * time.Second,
88+
},
89+
"decimal seconds": {
90+
header: "10.1",
91+
err: "couldn't parse retry-after header as an integer number of seconds or a date. Value was: \"10.1\"",
92+
},
93+
"far future date:": {
94+
header: futureTimeString,
95+
delay: time.Until(futureTime),
96+
},
97+
"empty string": {
98+
header: "",
99+
err: `couldn't parse retry-after header as an integer number of seconds or a date. Value was: ""`,
100+
},
101+
"some text": {
102+
header: "beep boop",
103+
err: `couldn't parse retry-after header as an integer number of seconds or a date. Value was: "beep boop"`,
104+
},
105+
} {
106+
t.Run(name, func(t *testing.T) {
107+
delay, err := parseDelay(tc.header)
108+
if tc.err != "" {
109+
require.EqualError(t, err, tc.err)
110+
} else {
111+
require.NoError(t, err)
112+
require.InDelta(t, tc.delay/time.Second, delay/time.Second, 1)
113+
}
114+
})
115+
}
116+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module github.com/CodeYourFuture/immersive-go-course/projects/output-and-error-handling
2+
3+
go 1.19
4+
5+
require github.com/stretchr/testify v1.8.1
6+
7+
require (
8+
github.com/davecgh/go-spew v1.1.1 // indirect
9+
github.com/pmezard/go-difflib v1.0.0 // indirect
10+
gopkg.in/yaml.v3 v3.0.1 // indirect
11+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
7+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
8+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
9+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
10+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
11+
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
12+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
13+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
14+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
15+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
16+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
17+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
8+
"github.com/CodeYourFuture/immersive-go-course/projects/output-and-error-handling/fetcher"
9+
)
10+
11+
func main() {
12+
f := fetcher.WeatherFetcher{}
13+
// Loop because we may need to retry.
14+
for {
15+
if weather, err := f.Fetch("http://localhost:8080/"); err != nil {
16+
// If we're told to retry, do so.
17+
if errors.Is(err, fetcher.ErrRetry) {
18+
continue
19+
}
20+
// Otherwise tell the user there was an error and give up.
21+
fmt.Fprintf(os.Stderr, "Error getting weather: %v\n", err)
22+
os.Exit(1)
23+
} else {
24+
// Print out the weather and be happy.
25+
fmt.Fprintf(os.Stdout, "%s\n", weather)
26+
break
27+
}
28+
}
29+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package testutils
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type responseOrError struct {
14+
response *http.Response
15+
err error
16+
}
17+
18+
type MockRoundTripper struct {
19+
t *testing.T
20+
responses []responseOrError
21+
requestCount int
22+
}
23+
24+
func NewMockRoundTripper(t *testing.T) *MockRoundTripper {
25+
return &MockRoundTripper{
26+
t: t,
27+
}
28+
}
29+
30+
func (m *MockRoundTripper) StubResponse(statusCode int, body string, header http.Header) {
31+
// We need to stub out a fair bit of the HTTP response in for the Go HTTP client to accept our response.
32+
response := &http.Response{
33+
Body: io.NopCloser(strings.NewReader(body)),
34+
ContentLength: int64(len(body)),
35+
Header: header,
36+
Proto: "HTTP/1.1",
37+
ProtoMajor: 1,
38+
ProtoMinor: 1,
39+
Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)),
40+
StatusCode: statusCode,
41+
}
42+
m.responses = append(m.responses, responseOrError{response: response})
43+
}
44+
45+
func (m *MockRoundTripper) StubErrorResponse(err error) {
46+
m.responses = append(m.responses, responseOrError{err: err})
47+
}
48+
49+
func (m *MockRoundTripper) AssertGotRightCalls() {
50+
m.t.Helper()
51+
52+
require.Equalf(m.t, len(m.responses), m.requestCount, "Expected %d requests, got %d", len(m.responses), m.requestCount)
53+
}
54+
55+
func (m *MockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
56+
m.t.Helper()
57+
58+
if len(m.responses) > m.requestCount+2 {
59+
m.t.Fatalf("MockRoundTripper expected %d requests but got more", len(m.responses))
60+
}
61+
resp := m.responses[m.requestCount]
62+
m.requestCount += 1
63+
return resp.response, resp.err
64+
}

0 commit comments

Comments
 (0)