07/03/2025 - GO
In this example we are going to use sync.Map to store API authentication tokens and automatically expire them before their actual expiry time. This way, we will do our best to avoid using already expired tokens when making API calls. If the token is expired, we obtain a new one and store it again. This is a simple cycle where tokens are recycled. The main point here is, how sync.Map is used for this purpose, not really how the API client is used so try to focus on the memory
package.
├── main.go
└── src
├── client
│ └── postcode
│ ├── client.go
│ └── model.go
├── model
│ └── client
│ └── location
│ └── address.go
└── storage
└── memory
├── memory.go
└── memory_test.go
Here we are making sure that expired tokens are checked once every 30 seconds. Also, we are making sure that the actual tokens expiry time is earlier than 5 minutes.
package main
import (
"context"
"log"
"time"
"random/src/client/postcode"
"random/src/storage/memory"
)
func main() {
ctx := context.Background()
storage := memory.New(ctx, memory.Config{
RecycleInterval: time.Second * 30,
})
client := postcode.Client{
Config: postcode.Config{
Address: "https://api.location.com",
ClientID: "client-d",
ClientSecret: "client-secret",
AuthTokenKey: "postcode_auth_token",
AuthTokenEarlyExpiry: time.Minute * 5,
},
StorageManager: storage,
}
for {
if adr, err := client.FindAddress(ctx, "PC 123"); err != nil {
log.Println(err)
} else {
log.Println(adr.Postcode)
}
time.Sleep(time.Second)
}
}
package postcode
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"random/src/model/client/location"
)
// storageManager exposes contract for the storage layer.
type storageManager interface {
Store(ctx context.Context, key string, val any, exp *time.Time)
Load(ctx context.Context, key string) (any, bool)
Delete(ctx context.Context, key string)
}
type Config struct {
// Address represents APIs base URL.
Address string
// ClientID represents client's authentication identifier.
ClientID string
// ClientSecret represents client's authentication secret.
ClientSecret string
// AuthTokenKey represents the key for the token in storage.
AuthTokenKey string
// AuthTokenEarlyExpiry is used to truncate a duration from the original
// expiry time of the token to force early expiry. e.g. 5 minutes
AuthTokenEarlyExpiry time.Duration
}
// Client provides functionality to interact with the Postcode API.
type Client struct {
Config Config
StorageManager storageManager
RoundTripper http.RoundTripper
}
// FindAddress finds an address for the given postcode. In case of 401 response,
// token is deleted from the storage in order to force obtaining a new one for
// the next call.
func (c Client) FindAddress(ctx context.Context, postcode string) (location.Address, error) {
token, err := c.authenticate(ctx)
if err != nil {
return location.Address{}, fmt.Errorf("unable to authenticate: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Config.Address+"/api/v1/addresses/"+postcode, nil)
if err != nil {
return location.Address{}, fmt.Errorf("build request: %w", err)
}
req.Header.Add("Authorization", token.TokenType+" "+token.AccessToken)
res, err := c.RoundTripper.RoundTrip(req)
if err != nil {
return location.Address{}, fmt.Errorf("send request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
if res.StatusCode == http.StatusUnauthorized {
go c.StorageManager.Delete(ctx, c.Config.AuthTokenKey)
}
return location.Address{}, fmt.Errorf("status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return location.Address{}, fmt.Errorf("read response body: %w", err)
}
var loc location.Address
if err := json.Unmarshal(body, &loc); err != nil {
return location.Address{}, fmt.Errorf("unmarshal response body: %w", err)
}
return loc, nil
}
// authenticate returns the auth token from the storage. If not found, API is
// authenticated, token is stored in storage for reuse then returned.
func (c Client) authenticate(ctx context.Context) (authToken, error) {
val, ok := c.StorageManager.Load(ctx, c.Config.AuthTokenKey)
if ok {
if token, ok := val.(authToken); ok {
return token, nil
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Config.Address+"/auth/v1", nil)
if err != nil {
return authToken{}, fmt.Errorf("build request: %w", err)
}
req.SetBasicAuth(c.Config.ClientID, c.Config.ClientSecret)
res, err := c.RoundTripper.RoundTrip(req)
if err != nil {
return authToken{}, fmt.Errorf("send request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return authToken{}, fmt.Errorf("status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return authToken{}, fmt.Errorf("read response body: %w", err)
}
var token authToken
if err := json.Unmarshal(body, &token); err != nil {
return authToken{}, fmt.Errorf("unmarshal response body: %w", err)
}
expiry := time.Now().UTC().Add(time.Second * time.Duration(token.ExpiresIn)).Add(-c.Config.AuthTokenEarlyExpiry)
go c.StorageManager.Store(ctx, c.Config.AuthTokenKey, token, &expiry)
return token, nil
}
package postcode
type authToken struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
package location
type Address struct {
Postcode string `json:"postcode"`
Country string `json:"country"`
}
package memory
import (
"context"
"sync"
"time"
)
type Config struct {
// RecycleInterval sets the interval which helps delete expired or about to
// expire items. It is better to keep it reasonable shorter so deletion
// process runs as often as possible but not too often because each run
// creates a new goroutine which would consume unreasonable amount of system
// resources. e.g. 30 seconds
RecycleInterval time.Duration
}
// Memory provides functionality to either permanently or temporarily store
// items in memory. Temporary items are cleaned up at regular intervals which
// is defined within the `config` parameter.
type Memory struct {
config Config
items *sync.Map
temps *sync.Map
}
func New(ctx context.Context, config Config) Memory {
memory := Memory{
config: config,
items: &sync.Map{},
temps: &sync.Map{},
}
go memory.recycle(ctx)
return memory
}
// Store permanently stores a new item or overrides existing one. If the item
// has an expiry time, it is also stored in a temporary store which will be
// cleaned up within the `recycle` method.
func (m Memory) Store(_ context.Context, key string, val any, exp *time.Time) {
m.items.Store(key, val)
if exp != nil {
m.temps.Store(key, exp)
}
}
// Load returns an item if found otherwise returns boolean false which means
// item is not found.
func (m Memory) Load(_ context.Context, key string) (any, bool) {
return m.items.Load(key)
}
// Delete deletes items by their key.
func (m Memory) Delete(_ context.Context, key string) {
m.items.Delete(key)
m.temps.Delete(key)
}
// recycle runs at regular intervals to delete expired or near to expire items.
func (m Memory) recycle(ctx context.Context) {
tick := time.NewTicker(m.config.RecycleInterval)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
m.temps.Range(func(key, val any) bool {
expiry, ok := val.(*time.Time)
if !ok {
return false
}
if time.Now().UTC().Before(*expiry) {
return true
}
m.items.Delete(key)
m.temps.Delete(key)
return true
})
}
}
}
package memory
import (
"context"
"testing"
"time"
)
func Test_Memory_New(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
memory := New(ctx, Config{RecycleInterval: time.Nanosecond})
expiry := time.Now().Add(time.Millisecond * 10)
memory.Store(ctx, "key", "val", &expiry)
val, found := memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
time.Sleep(time.Millisecond * 50)
val, found = memory.items.Load("key")
if val != nil {
t.Error("expected nil")
}
if found {
t.Error("expected to not find item")
}
val, found = memory.temps.Load("key")
if val != nil {
t.Error("expected nil")
}
if found {
t.Error("expected to not find item")
}
cancel()
}
func Test_Memory_store(t *testing.T) {
ctx := context.Background()
memory := New(ctx, Config{RecycleInterval: time.Second})
t.Run("store with expiry", func(t *testing.T) {
expiry := time.Now().Add(time.Minute)
memory.Store(ctx, "key-1", "val", &expiry)
val, found := memory.items.Load("key-1")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key-1")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
})
t.Run("store without expiry", func(t *testing.T) {
memory.Store(ctx, "key-2", "val", nil)
val, found := memory.items.Load("key-2")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key-2")
if val != nil {
t.Error("expected nil")
}
if found {
t.Error("expected to not find item")
}
})
}
func Test_Memory_load(t *testing.T) {
var (
ctx = context.Background()
exp = time.Now().Add(time.Minute)
)
memory := New(ctx, Config{RecycleInterval: time.Second})
memory.Store(ctx, "key", "val", &exp)
val, found := memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
}
func Test_Memory_delete(t *testing.T) {
var (
ctx = context.Background()
exp = time.Now().Add(time.Minute)
)
memory := New(ctx, Config{RecycleInterval: time.Second})
memory.Store(ctx, "key", "val", &exp)
val, found := memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
memory.Delete(ctx, "key")
val, found = memory.items.Load("key")
if val != nil {
t.Error("expected nil")
}
if found {
t.Error("expected to not find item")
}
val, found = memory.temps.Load("key")
if val != nil {
t.Error("expected nil")
}
if found {
t.Error("expected to not find item")
}
}
func Test_Memory_recycle(t *testing.T) {
t.Run("exit as soon as context is cancelled and avoid deleting item", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), time.Nanosecond)
t.Cleanup(func() {
cancel()
})
expiry := time.Now().Add(time.Minute)
memory := New(ctx, Config{RecycleInterval: time.Second})
memory.Store(ctx, "key", "val", &expiry)
val, found := memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
memory.recycle(ctx)
val, found = memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
})
t.Run("not expired item is not deleted", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond*10)
t.Cleanup(func() {
cancel()
})
expiry := time.Now().Add(time.Minute)
memory := New(ctx, Config{RecycleInterval: time.Nanosecond})
memory.Store(ctx, "key", "val", &expiry)
val, found := memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
memory.recycle(ctx)
val, found = memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
})
t.Run("expired item is deleted", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond*20)
t.Cleanup(func() {
cancel()
})
expiry := time.Now().Add(time.Millisecond)
memory := New(ctx, Config{RecycleInterval: time.Nanosecond})
memory.Store(ctx, "key", "val", &expiry)
val, found := memory.items.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
val, found = memory.temps.Load("key")
if val == nil {
t.Error("expected not nil")
}
if !found {
t.Error("expected to find item")
}
memory.recycle(ctx)
val, found = memory.items.Load("key")
if val != nil {
t.Error("expected nil")
}
if found {
t.Error("expected to not find item")
}
val, found = memory.temps.Load("key")
if val != nil {
t.Error("expected nil")
}
if found {
t.Error("expected to not find item")
}
cancel()
})
}