Add a cached http client

This commit is contained in:
Deluan 2021-02-07 23:26:05 -05:00 committed by Deluan Quintão
parent 9d24106066
commit 28cdf1e693
11 changed files with 234 additions and 14 deletions

View File

@ -34,6 +34,8 @@ const (
PlaceholderAlbumArt = "navidrome-600x600.png"
PlaceholderAvatar = "logo-192x192.png"
DefaultCachedHttpClientTTL = 10 * time.Second
)
// Cache options

View File

@ -0,0 +1,17 @@
package agents
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestAgents(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Agents Test Suite")
}

View File

@ -0,0 +1,102 @@
package agents
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/ReneKroon/ttlcache/v2"
"github.com/navidrome/navidrome/log"
)
const cacheSizeLimit = 1000
type CachedHTTPClient struct {
cache *ttlcache.Cache
hc httpDoer
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
type requestData struct {
Method string
Header http.Header
URL string
Body *string
}
func NewCachedHTTPClient(wrapped httpDoer, ttl time.Duration) *CachedHTTPClient {
c := &CachedHTTPClient{hc: wrapped}
c.cache = ttlcache.NewCache()
c.cache.SetCacheSizeLimit(cacheSizeLimit)
c.cache.SkipTTLExtensionOnHit(true)
c.cache.SetLoaderFunction(func(key string) (interface{}, time.Duration, error) {
req, err := c.deserializeReq(key)
if err != nil {
return nil, 0, err
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, 0, err
}
return c.serializeResponse(resp), ttl, nil
})
c.cache.SetNewItemCallback(func(key string, value interface{}) {
log.Trace("New request cached", "req", key, "resp", value)
})
return c
}
func (c *CachedHTTPClient) Do(req *http.Request) (*http.Response, error) {
key := c.serializeReq(req)
respStr, err := c.cache.Get(key)
if err != nil {
return nil, err
}
return c.deserializeResponse(req, respStr.(string))
}
func (c *CachedHTTPClient) serializeReq(req *http.Request) string {
data := requestData{
Method: req.Method,
Header: req.Header,
URL: req.URL.String(),
}
if req.Body != nil {
bodyData, _ := ioutil.ReadAll(req.Body)
bodyStr := base64.StdEncoding.EncodeToString(bodyData)
data.Body = &bodyStr
}
j, _ := json.Marshal(&data)
return string(j)
}
func (c *CachedHTTPClient) deserializeReq(reqStr string) (*http.Request, error) {
var data requestData
_ = json.Unmarshal([]byte(reqStr), &data)
var body io.Reader
if data.Body != nil {
bodyStr, _ := base64.StdEncoding.DecodeString(*data.Body)
body = strings.NewReader(string(bodyStr))
}
return http.NewRequest(data.Method, data.URL, body)
}
func (c *CachedHTTPClient) serializeResponse(resp *http.Response) string {
var b = &bytes.Buffer{}
_ = resp.Write(b)
return b.String()
}
func (c *CachedHTTPClient) deserializeResponse(req *http.Request, respStr string) (*http.Response, error) {
r := bufio.NewReader(strings.NewReader(respStr))
return http.ReadResponse(r, req)
}

View File

@ -0,0 +1,81 @@
package agents
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/consts"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("CachedHttpClient", func() {
Context("Default TTL", func() {
var chc *CachedHTTPClient
var ts *httptest.Server
var requestsReceived int
BeforeEach(func() {
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestsReceived++
_, _ = fmt.Fprintf(w, "Hello, %s", r.URL.Query()["name"])
}))
chc = NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
})
AfterEach(func() {
defer ts.Close()
})
It("caches repeated requests", func() {
r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil)
resp, err := chc.Do(r)
Expect(err).To(BeNil())
body, err := ioutil.ReadAll(resp.Body)
Expect(err).To(BeNil())
Expect(string(body)).To(Equal("Hello, [doe]"))
Expect(requestsReceived).To(Equal(1))
// Same request
r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil)
resp, err = chc.Do(r)
Expect(err).To(BeNil())
body, err = ioutil.ReadAll(resp.Body)
Expect(err).To(BeNil())
Expect(string(body)).To(Equal("Hello, [doe]"))
Expect(requestsReceived).To(Equal(1))
// Different request
r, _ = http.NewRequest("GET", ts.URL, nil)
resp, err = chc.Do(r)
Expect(err).To(BeNil())
body, err = ioutil.ReadAll(resp.Body)
Expect(err).To(BeNil())
Expect(string(body)).To(Equal("Hello, []"))
Expect(requestsReceived).To(Equal(2))
})
It("expires responses after TTL", func() {
requestsReceived = 0
chc = NewCachedHTTPClient(http.DefaultClient, 10*time.Millisecond)
r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil)
_, err := chc.Do(r)
Expect(err).To(BeNil())
Expect(requestsReceived).To(Equal(1))
// Wait more than the TTL
time.Sleep(50 * time.Millisecond)
// Same request
r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil)
_, err = chc.Do(r)
Expect(err).To(BeNil())
Expect(requestsReceived).To(Equal(2))
})
})
})

View File

@ -5,6 +5,7 @@ import (
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/lastfm"
"github.com/navidrome/navidrome/log"
)
@ -26,7 +27,8 @@ func lastFMConstructor(ctx context.Context) Interface {
apiKey: conf.Server.LastFM.ApiKey,
lang: conf.Server.LastFM.Language,
}
l.client = lastfm.NewClient(l.apiKey, l.lang, http.DefaultClient)
hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
l.client = lastfm.NewClient(l.apiKey, l.lang, hc)
return l
}
@ -103,7 +105,7 @@ func (l *lastfmAgent) GetTopSongs(artistName, mbid string, count int) ([]Track,
func (l *lastfmAgent) callArtistGetInfo(name string, mbid string) (*lastfm.Artist, error) {
a, err := l.client.ArtistGetInfo(l.ctx, name)
if err != nil {
log.Error(l.ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid)
log.Error(l.ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid, err)
return nil, err
}
return a, nil
@ -112,7 +114,7 @@ func (l *lastfmAgent) callArtistGetInfo(name string, mbid string) (*lastfm.Artis
func (l *lastfmAgent) callArtistGetSimilar(name string, mbid string, limit int) ([]lastfm.Artist, error) {
s, err := l.client.ArtistGetSimilar(l.ctx, name, limit)
if err != nil {
log.Error(l.ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid)
log.Error(l.ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid, err)
return nil, err
}
return s, nil
@ -121,7 +123,7 @@ func (l *lastfmAgent) callArtistGetSimilar(name string, mbid string, limit int)
func (l *lastfmAgent) callArtistGetTopTracks(artistName, mbid string, count int) ([]lastfm.Track, error) {
t, err := l.client.ArtistGetTopTracks(l.ctx, artistName, count)
if err != nil {
log.Error(l.ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid)
log.Error(l.ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid, err)
return nil, err
}
return t, nil

View File

@ -8,6 +8,7 @@ import (
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/spotify"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -31,7 +32,8 @@ func spotifyConstructor(ctx context.Context) Interface {
id: conf.Server.Spotify.ID,
secret: conf.Server.Spotify.Secret,
}
l.client = spotify.NewClient(l.id, l.secret, http.DefaultClient)
hc := NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL)
l.client = spotify.NewClient(l.id, l.secret, hc)
return l
}

View File

@ -14,18 +14,18 @@ const (
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
)
type HttpClient interface {
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func NewClient(apiKey string, lang string, hc HttpClient) *Client {
func NewClient(apiKey string, lang string, hc httpDoer) *Client {
return &Client{apiKey, lang, hc}
}
type Client struct {
apiKey string
lang string
hc HttpClient
hc httpDoer
}
func (c *Client) makeRequest(params url.Values) (*Response, error) {

View File

@ -21,18 +21,18 @@ var (
ErrNotFound = errors.New("spotify: not found")
)
type HttpClient interface {
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func NewClient(id, secret string, hc HttpClient) *Client {
func NewClient(id, secret string, hc httpDoer) *Client {
return &Client{id, secret, hc}
}
type Client struct {
id string
secret string
hc HttpClient
hc httpDoer
}
func (c *Client) SearchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
@ -66,9 +66,10 @@ func (c *Client) authorize(ctx context.Context) (string, error) {
payload := url.Values{}
payload.Add("grant_type", "client_credentials")
req, _ := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(payload.Encode()))
encodePayload := payload.Encode()
req, _ := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(payload.Encode())))
req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
auth := c.id + ":" + c.secret
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))

3
go.mod
View File

@ -6,6 +6,7 @@ require (
code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee
github.com/ClickHouse/clickhouse-go v1.4.3 // indirect
github.com/Masterminds/squirrel v1.5.0
github.com/ReneKroon/ttlcache/v2 v2.3.0
github.com/astaxie/beego v1.12.3
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/cespare/reflex v0.3.0
@ -47,7 +48,7 @@ require (
github.com/ziutek/mymysql v1.5.4 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb
golang.org/x/tools v0.0.0-20210105210202-9ed45478a130
golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/djherbis/atime.v1 v1.0.0
gopkg.in/djherbis/stream.v1 v1.3.1

12
go.sum
View File

@ -30,6 +30,8 @@ github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OpenPeeDeeP/depguard v1.0.1 h1:VlW4R6jmBIv3/u1JNlawEvJMM4J+dPORPaZasQee8Us=
github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM=
github.com/ReneKroon/ttlcache/v2 v2.3.0 h1:qZnUjRKIrbKHH6vF5T7Y9Izn5ObfTZfyYpGhvz2BKPo=
github.com/ReneKroon/ttlcache/v2 v2.3.0/go.mod h1:zbo6Pv/28e21Z8CzzqgYRArQYGYtjONRxaAKGxzQvG4=
github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@ -37,6 +39,7 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
github.com/alvaroloes/enumer v1.1.2/go.mod h1:FxrjvuXoDAx9isTJrv4c+T410zFi0DtXIT0m65DJ+Wo=
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@ -443,6 +446,7 @@ github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuK
github.com/onsi/gomega v1.10.5 h1:7n6FEkpFmfCoo2t+YYqXH0evK+a9ICQz0xcAy9dYcaQ=
github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/name v0.0.0-20180628100202-0fd16699aae1/go.mod h1:eD5JxqMiuNYyFNmyY9rkJ/slN8y59oEu4Ei7F8OoKWQ=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.0.1/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
@ -621,6 +625,8 @@ go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@ -650,6 +656,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@ -766,6 +774,7 @@ golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524210228-3d17549cdc6b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
@ -779,6 +788,7 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@ -806,6 +816,8 @@ golang.org/x/tools v0.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210102185154-773b96fafca2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105210202-9ed45478a130 h1:8qSBr5nyKsEgkP918Pu5FFDZpTtLIjXSo6mrtdVOFfk=
golang.org/x/tools v0.0.0-20210105210202-9ed45478a130/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064 h1:BmCFkEH4nJrYcAc2L08yX5RhYGD4j58PTMkEUDkpz2I=
golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=