Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rate limit system #3

Merged
merged 6 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/configs/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,17 @@ linters:

issues:
exclude-use-default: false

linters-settings:
depguard:
rules:
prevent_unmaintained_packages:
list-mode: lax # allow unless explicitely denied
files:
- $all
- "!$test"
allow:
- $gostd
deny:
- pkg: io/ioutil
desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil"
2 changes: 1 addition & 1 deletion .github/workflows/build-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:

- name: install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.52.0
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.56.2

- name: fetch repository changes
run: |
Expand Down
15 changes: 15 additions & 0 deletions internal/controller/http/v1/notification.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package v1

import (
"context"
"net/http"
"notifier/internal/entity"
"notifier/internal/usecase"

"github.com/labstack/echo/v4"
Expand All @@ -17,5 +20,17 @@ func NewNotificationController(uc usecase.NotificationUseCase) *NotificationCont
}

func (r *NotificationController) SendNotifications(c echo.Context) error {
ctx := context.Background()
n, decodeErr := entity.NewNotificationFromRequest(c.Request().Body)
if decodeErr != nil {
return c.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
}

notifyErr := r.uc.Notify(ctx, n)
if notifyErr != nil {
return c.String(http.StatusServiceUnavailable, notifyErr.Error())
}

c.Response().WriteHeader(http.StatusCreated)
return nil
}
31 changes: 28 additions & 3 deletions internal/entity/types.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
package entity

import (
"encoding/json"
"io"
)

const (
StatusNotificationType = "status"
NewsNotificationType = "news"
MarketingNotificationType = "marketing"
)

type Notification struct {
Content string
Type string
Recipient string
Content string `json:"content"`
Type string `json:"type"`
Recipient string `json:"recipient"`
}

func NewNotification(content, typ, recipient string) *Notification {
Expand All @@ -13,3 +24,17 @@ func NewNotification(content, typ, recipient string) *Notification {
Recipient: recipient,
}
}

func NewNotificationFromRequest(r io.Reader) (*Notification, error) {
// Create a decoder for the response body.
decoder := json.NewDecoder(r)

// Decode the JSON data into a struct.
var n Notification
decodeBody := decoder.Decode(&n)
if decodeBody != nil {
return nil, decodeBody
}

return &n, nil
}
5 changes: 4 additions & 1 deletion internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import (
)

func Run(cfg *config.Config) {
nh := usecase.BuildRateLimitChain()
em := usecase.NewEmailManager()
uc := usecase.NewNotificationUseCase(em)
uc := usecase.NewNotificationUseCase(em, nh)
nc := v1.NewNotificationController(uc)

e := echo.New()
e.POST("/notifications", nc.SendNotifications)

e.Logger.Fatal(e.Start(fmt.Sprintf(":%s", cfg.App.Port)))

httpServer := httpserver.New(e)
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
Expand Down
60 changes: 60 additions & 0 deletions internal/usecase/base_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package usecase

import (
"fmt"
"notifier/internal/entity"
"sync"
"time"
)

type RateLimitRule struct {
MaxPerUnit int
Unit time.Duration
}

// BaseHandler handles rate limits for the "any" notification type.
type BaseHandler struct {
MaxPerUnit int // Maximum number of notifications allowed per unit (e.g., 2)
Unit time.Duration // Unit of time for the rate limit (e.g., time.Minute)
lastSentTimes map[string]time.Time // Maps recipient emails to the last time sent
mutex *sync.Mutex // Protects lastSentTimes from concurrent access
Next NotificationRateLimitHandler // Next handler in the chain
}

func (h *BaseHandler) Validate(notification *entity.Notification) error {
h.mutex.Lock()
defer h.mutex.Unlock()

lastSentTime, ok := h.lastSentTimes[notification.Recipient]
if !ok || time.Since(lastSentTime) >= h.Unit {
h.lastSentTimes[notification.Recipient] = time.Now()
return nil
}

return fmt.Errorf("notification rate limit exceeded for type %q: recipient %q", notification.Type, notification.Recipient)
}

func BuildRateLimitChain() NotificationRateLimitHandler {
// TODO: it could be stored on db to be configurable
rules := map[string]RateLimitRule{
entity.StatusNotificationType: {MaxPerUnit: 2, Unit: time.Minute},
entity.NewsNotificationType: {MaxPerUnit: 1, Unit: time.Hour * 24},
entity.MarketingNotificationType: {MaxPerUnit: 3, Unit: time.Hour},
}

// TODO: abstract the storage to be scalable
// i.e: Redis, Memcache...(ephemeral storage with high response)
lastSentTimes := make(map[string]time.Time)
m := &sync.Mutex{}

sh := NewStatusHandler(lastSentTimes, rules[entity.StatusNotificationType], m)
nh := NewNewsHandler(lastSentTimes, rules[entity.NewsNotificationType], m)
mh := NewMarketingHandler(lastSentTimes, rules[entity.MarketingNotificationType], m)
uh := &UnknownHandler{}

sh.Next = nh
nh.Next = mh
mh.Next = uh // the UnknownHandler should be the last one

return sh
}
5 changes: 4 additions & 1 deletion internal/usecase/email.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package usecase

import "fmt"

type emailManager struct {
}

Expand All @@ -8,5 +10,6 @@ func NewEmailManager() EmailManager {
}

func (m *emailManager) Send(subject, addressee, body string) error {
panic("implement me")
fmt.Printf("--> Mock Email Manager - subject %s, Receipent:%s, content:%s\n", subject, addressee, body)
return nil
}
7 changes: 7 additions & 0 deletions internal/usecase/interfaces.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package usecase defines the service business logic
package usecase

import (
Expand All @@ -17,3 +18,9 @@ type EmailManager interface {
// body is the message content
Send(subject, addressee, body string) error
}

// NotificationRateLimitHandler defines the interface for a handler in the chain of responsibility.
type NotificationRateLimitHandler interface {
Handle(n *entity.Notification) error
SetNext(next NotificationRateLimitHandler)
}
95 changes: 95 additions & 0 deletions internal/usecase/limit_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package usecase

import (
"fmt"
"notifier/internal/entity"
"sync"
"time"
)

// StatusHandler handles rate limits for the "status" notification type.
type StatusHandler struct {
*BaseHandler
}

func NewStatusHandler(lastSentTimes map[string]time.Time, r RateLimitRule, m *sync.Mutex) *StatusHandler {
return &StatusHandler{&BaseHandler{
MaxPerUnit: r.MaxPerUnit,
Unit: r.Unit,
lastSentTimes: lastSentTimes,
mutex: m,
}}
}

func (h *StatusHandler) Handle(n *entity.Notification) error {
if entity.StatusNotificationType != n.Type {
return h.Next.Handle(n) // Pass to Next handler
}
return h.Validate(n)
}

func (h *StatusHandler) SetNext(next NotificationRateLimitHandler) {
h.Next = next
}

// MarketingHandler handles rate limits for the "marketing" notification type.
type MarketingHandler struct {
*BaseHandler
}

func NewMarketingHandler(lastSentTimes map[string]time.Time, r RateLimitRule, m *sync.Mutex) *MarketingHandler {
return &MarketingHandler{&BaseHandler{
MaxPerUnit: r.MaxPerUnit,
Unit: r.Unit,
lastSentTimes: lastSentTimes,
mutex: m,
}}
}

func (h *MarketingHandler) Handle(n *entity.Notification) error {
if entity.MarketingNotificationType != n.Type {
return h.Next.Handle(n) // Pass to Next handler
}
return h.Validate(n)
}

func (h *MarketingHandler) SetNext(next NotificationRateLimitHandler) {
h.Next = next
}

// NewsHandler handles rate limits for the "news" notification type.
type NewsHandler struct {
*BaseHandler
}

func NewNewsHandler(lastSentTimes map[string]time.Time, r RateLimitRule, m *sync.Mutex) *NewsHandler {
return &NewsHandler{&BaseHandler{
MaxPerUnit: r.MaxPerUnit,
Unit: r.Unit,
lastSentTimes: lastSentTimes,
mutex: m,
}}
}

func (h *NewsHandler) Handle(n *entity.Notification) error {
if entity.NewsNotificationType != n.Type {
return h.Next.Handle(n) // Pass to Next handler
}
return h.Validate(n)
}

func (h *NewsHandler) SetNext(next NotificationRateLimitHandler) {
h.Next = next
}

type UnknownHandler struct {
next NotificationRateLimitHandler
}

func (h *UnknownHandler) Handle(n *entity.Notification) error {
return fmt.Errorf("notification rate limit unhandled for type %q: recipient %q", n.Type, n.Recipient)
}

func (h *UnknownHandler) SetNext(next NotificationRateLimitHandler) {
h.next = next
}
42 changes: 42 additions & 0 deletions internal/usecase/limit_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package usecase

import (
"notifier/internal/entity"
"testing"

"github.com/stretchr/testify/suite"
)

type notificationTest struct {
n *entity.Notification
expectedAllowed bool
}

type limitHandlerSuite struct {
suite.Suite
}

func TestLimitHandlerSuite(t *testing.T) {
suite.Run(t, new(limitHandlerSuite))
}

func (s *limitHandlerSuite) TestChainOfResponsibility() {
handler := BuildRateLimitChain()

// Sending notifications (some will be rejected due to rate limits)
notifications := []notificationTest{
{entity.NewNotification("", entity.StatusNotificationType, "[email protected]"), true},
{entity.NewNotification("", entity.StatusNotificationType, "[email protected]"), false}, // Second one will be rejected
{entity.NewNotification("", entity.NewsNotificationType, "[email protected]"), true},
{entity.NewNotification("", entity.MarketingNotificationType, "[email protected]"), true},
{entity.NewNotification("", entity.MarketingNotificationType, "[email protected]"), false}, // Second one will be rejected
{entity.NewNotification("", "unknown", "[email protected]"), false}, // Not Allowed (no handler for "unknown")
}

for _, tc := range notifications {
err := handler.Handle(tc.n)
if !tc.expectedAllowed {
s.NotNil(err)
}
}
}
21 changes: 17 additions & 4 deletions internal/usecase/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,32 @@ package usecase

import (
"context"

"notifier/internal/entity"
)

type notificationUseCase struct {
notifier EmailManager
notifier EmailManager
rateLimitHandler NotificationRateLimitHandler
}

func NewNotificationUseCase(notifier EmailManager) NotificationUseCase {
func NewNotificationUseCase(notifier EmailManager, h NotificationRateLimitHandler) NotificationUseCase {
return &notificationUseCase{
notifier: notifier,
notifier: notifier,
rateLimitHandler: h,
}
}

func (uc *notificationUseCase) Notify(ctx context.Context, n *entity.Notification) error {
panic("implement me")
handleErr := uc.rateLimitHandler.Handle(n)
if handleErr != nil {
return handleErr
}

sendErr := uc.notifier.Send("Automatic email", n.Recipient, n.Content)
if sendErr != nil {
return sendErr
}

return nil
}
Loading