├── .circleci └── config.yml ├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── _examples ├── consumer │ └── main.go ├── producer │ └── main.go └── pubsub │ └── main.go ├── bind_args.go ├── bind_args_test.go ├── consumer_message.go ├── consumer_message_test.go ├── declare_args.go ├── declare_args_test.go ├── docker-compose.yml ├── error.go ├── integration └── rabbus_integration_test.go ├── internal └── amqp │ └── amqp.go ├── options.go ├── rabbus.go └── rabbus_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: circleci/golang:latest 7 | - image: "rabbitmq:3.7-management-alpine" 8 | 9 | working_directory: "/go/src/github.com/rafaeljesus/rabbus" 10 | 11 | environment: 12 | AMQP_DSN: "amqp://guest:guest@localhost:5672/" 13 | AMQP_MANAGEMENT_PORT: "http://localhost:15672/api" 14 | 15 | steps: 16 | - checkout 17 | - run: make deps 18 | - run: make test 19 | - run: make integration-test-ci 20 | 21 | workflows: 22 | version: 2 23 | run-tests: 24 | jobs: 25 | - build 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /Godeps 3 | /dist 4 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/rafaeljesus/retry-go" 7 | packages = ["."] 8 | revision = "3bbade4f4fab0cf8e41928da5869760c17a037db" 9 | 10 | [[projects]] 11 | name = "github.com/sony/gobreaker" 12 | packages = ["."] 13 | revision = "e9556a45379ef1da12e54847edb2fb3d7d566f36" 14 | version = "0.3.0" 15 | 16 | [[projects]] 17 | branch = "master" 18 | name = "github.com/streadway/amqp" 19 | packages = ["."] 20 | revision = "ff791c2d22d3f1588b4e2cc71a9fba5e1da90654" 21 | 22 | [solve-meta] 23 | analyzer-name = "dep" 24 | analyzer-version = 1 25 | inputs-digest = "5cc4ba9bb3fe8b480c05e9186b2d02a9846d92a8cabd8d2ee642fdb6969ad173" 26 | solver-name = "gps-cdcl" 27 | solver-version = 1 28 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | branch = "master" 26 | name = "github.com/rafaeljesus/retry-go" 27 | 28 | [[constraint]] 29 | name = "github.com/sony/gobreaker" 30 | version = "0.3.0" 31 | 32 | [[constraint]] 33 | branch = "master" 34 | name = "github.com/streadway/amqp" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Rafael Jesus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all deps test integration-test-ci 2 | 3 | all: deps test integration-test 4 | 5 | deps: 6 | @go get -u github.com/golang/dep/cmd/dep 7 | @dep ensure -v -vendor-only 8 | 9 | test: 10 | @go test -v -race -cover 11 | 12 | integration-test: 13 | @docker-compose up -d 14 | @sleep 3 15 | AMQP_DSN="amqp://guest:guest@`docker-compose port rabbit 5672`/" \ 16 | AMQP_MANAGEMENT_PORT="http://`docker-compose port rabbit 15672`/api" \ 17 | go test -timeout=30s -v -cover integration/rabbus_integration_test.go -bench . 18 | @docker-compose down -v 19 | 20 | integration-test-ci: 21 | @go test -v -race -cover integration/rabbus_integration_test.go -bench . 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Rabbus 🚌 ✨ 2 | 3 | * A tiny wrapper over [amqp](https://github.com/streadway/amqp) exchanges and queues. 4 | * In memory retries with exponential backoff for sending messages. 5 | * Protect producer calls with [circuit breaker](https://github.com/sony/gobreaker). 6 | * Automatic reconnect to RabbitMQ broker when connection is lost. 7 | * Go channel API. 8 | 9 | ## Installation 10 | ```bash 11 | go get -u github.com/rafaeljesus/rabbus 12 | ``` 13 | 14 | ## Usage 15 | The rabbus package exposes an interface for emitting and listening RabbitMQ messages. 16 | 17 | ### Emit 18 | ```go 19 | import ( 20 | "context" 21 | "time" 22 | 23 | "github.com/rafaeljesus/rabbus" 24 | ) 25 | 26 | func main() { 27 | timeout := time.After(time.Second * 3) 28 | cbStateChangeFunc := func(name, from, to string) { 29 | // do something when state is changed 30 | } 31 | r, err := rabbus.New( 32 | rabbusDsn, 33 | rabbus.Durable(true), 34 | rabbus.Attempts(5), 35 | rabbus.Sleep(time.Second*2), 36 | rabbus.Threshold(3), 37 | rabbus.OnStateChange(cbStateChangeFunc), 38 | ) 39 | if err != nil { 40 | // handle error 41 | } 42 | 43 | defer func(r Rabbus) { 44 | if err := r.Close(); err != nil { 45 | // handle error 46 | } 47 | }(r) 48 | 49 | ctx, cancel := context.WithCancel(context.Background()) 50 | defer cancel() 51 | 52 | go r.Run(ctx) 53 | 54 | msg := rabbus.Message{ 55 | Exchange: "test_ex", 56 | Kind: "topic", 57 | Key: "test_key", 58 | Payload: []byte(`foo`), 59 | } 60 | 61 | r.EmitAsync() <- msg 62 | 63 | for { 64 | select { 65 | case <-r.EmitOk(): 66 | // message was sent 67 | case <-r.EmitErr(): 68 | // failed to send message 69 | case <-timeout: 70 | // handle timeout error 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | ### Listen 77 | ```go 78 | import ( 79 | "context" 80 | "encoding/json" 81 | "time" 82 | 83 | "github.com/rafaeljesus/rabbus" 84 | ) 85 | 86 | func main() { 87 | timeout := time.After(time.Second * 3) 88 | cbStateChangeFunc := func(name, from, to string) { 89 | // do something when state is changed 90 | } 91 | r, err := rabbus.New( 92 | rabbusDsn, 93 | rabbus.Durable(true), 94 | rabbus.Attempts(5), 95 | rabbus.Sleep(time.Second*2), 96 | rabbus.Threshold(3), 97 | rabbus.OnStateChange(cbStateChangeFunc), 98 | ) 99 | if err != nil { 100 | // handle error 101 | } 102 | 103 | defer func(r Rabbus) { 104 | if err := r.Close(); err != nil { 105 | // handle error 106 | } 107 | }(r) 108 | 109 | ctx, cancel := context.WithCancel(context.Background()) 110 | defer cancel() 111 | 112 | go r.Run(ctx) 113 | 114 | messages, err := r.Listen(rabbus.ListenConfig{ 115 | Exchange: "events_ex", 116 | Kind: "topic", 117 | Key: "events_key", 118 | Queue: "events_q", 119 | DeclareArgs: rabbus.NewDeclareArgs().WithMessageTTL(15 * time.Minute).With("foo", "bar"), 120 | BindArgs: rabbus.NewBindArgs().With("baz", "qux"), 121 | }) 122 | if err != nil { 123 | // handle errors during adding listener 124 | } 125 | defer close(messages) 126 | 127 | go func(messages chan ConsumerMessage) { 128 | for m := range messages { 129 | m.Ack(false) 130 | } 131 | }(messages) 132 | } 133 | ``` 134 | 135 | ## Contributing 136 | - Fork it 137 | - Create your feature branch (`git checkout -b my-new-feature`) 138 | - Commit your changes (`git commit -am 'Add some feature'`) 139 | - Push to the branch (`git push origin my-new-feature`) 140 | - Create new Pull Request 141 | 142 | ## Badges 143 | 144 | [![Build Status](https://circleci.com/gh/rafaeljesus/rabbus.svg?style=svg)](https://circleci.com/gh/rafaeljesus/rabbus) 145 | [![Go Report Card](https://goreportcard.com/badge/github.com/rafaeljesus/rabbus)](https://goreportcard.com/report/github.com/rafaeljesus/rabbus) 146 | [![Go Doc](https://godoc.org/github.com/rafaeljesus/rabbus?status.svg)](https://godoc.org/github.com/rafaeljesus/rabbus) 147 | 148 | --- 149 | 150 | > GitHub [@rafaeljesus](https://github.com/rafaeljesus)  ·  151 | > Medium [@_jesus_rafael](https://medium.com/@_jesus_rafael)  ·  152 | > Twitter [@_jesus_rafael](https://twitter.com/_jesus_rafael) 153 | -------------------------------------------------------------------------------- /_examples/consumer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "sync" 7 | "time" 8 | 9 | "github.com/rafaeljesus/rabbus" 10 | ) 11 | 12 | var ( 13 | rabbusDsn = "amqp://localhost:5672" 14 | timeout = time.After(time.Second * 3) 15 | wg sync.WaitGroup 16 | ) 17 | 18 | func main() { 19 | cbStateChangeFunc := func(name, from, to string) { 20 | // do something when state is changed 21 | } 22 | r, err := rabbus.New( 23 | rabbusDsn, 24 | rabbus.Durable(true), 25 | rabbus.Attempts(5), 26 | rabbus.Sleep(time.Second*2), 27 | rabbus.Threshold(3), 28 | rabbus.OnStateChange(cbStateChangeFunc), 29 | ) 30 | if err != nil { 31 | log.Fatalf("Failed to init rabbus connection %s", err) 32 | return 33 | } 34 | 35 | defer func(r *rabbus.Rabbus) { 36 | if err := r.Close(); err != nil { 37 | log.Fatalf("Failed to close rabbus connection %s", err) 38 | } 39 | }(r) 40 | 41 | ctx, cancel := context.WithCancel(context.Background()) 42 | defer cancel() 43 | 44 | go r.Run(ctx) 45 | 46 | messages, err := r.Listen(rabbus.ListenConfig{ 47 | Exchange: "consumer_test_ex", 48 | Kind: "direct", 49 | Key: "consumer_test_key", 50 | Queue: "consumer_test_q", 51 | }) 52 | if err != nil { 53 | log.Fatalf("Failed to create listener %s", err) 54 | return 55 | } 56 | defer close(messages) 57 | 58 | for { 59 | log.Println("Listening for messages...") 60 | 61 | m, ok := <-messages 62 | if !ok { 63 | log.Println("Stop listening messages!") 64 | break 65 | } 66 | 67 | m.Ack(false) 68 | 69 | log.Println("Message was consumed") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /_examples/producer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/rafaeljesus/rabbus" 9 | ) 10 | 11 | var ( 12 | rabbusDsn = "amqp://localhost:5672" 13 | timeout = time.After(time.Second * 3) 14 | ) 15 | 16 | func main() { 17 | cbStateChangeFunc := func(name, from, to string) { 18 | // do something when state is changed 19 | } 20 | r, err := rabbus.New( 21 | rabbusDsn, 22 | rabbus.Durable(true), 23 | rabbus.Attempts(5), 24 | rabbus.Sleep(time.Second*2), 25 | rabbus.Threshold(3), 26 | rabbus.OnStateChange(cbStateChangeFunc), 27 | ) 28 | if err != nil { 29 | log.Fatalf("Failed to init rabbus connection %s", err) 30 | return 31 | } 32 | 33 | defer func(r *rabbus.Rabbus) { 34 | if err := r.Close(); err != nil { 35 | log.Fatalf("Failed to close rabbus connection %s", err) 36 | } 37 | }(r) 38 | 39 | ctx, cancel := context.WithCancel(context.Background()) 40 | defer cancel() 41 | 42 | go r.Run(ctx) 43 | 44 | msg := rabbus.Message{ 45 | Exchange: "producer_test_ex", 46 | Kind: "direct", 47 | Key: "producer_test_key", 48 | Payload: []byte(`foo`), 49 | DeliveryMode: rabbus.Persistent, 50 | } 51 | 52 | r.EmitAsync() <- msg 53 | 54 | outer: 55 | for { 56 | select { 57 | case <-r.EmitOk(): 58 | log.Println("Message was sent") 59 | break outer 60 | case err := <-r.EmitErr(): 61 | log.Fatalf("Failed to send message %s", err) 62 | break outer 63 | case <-timeout: 64 | log.Println("got time out error") 65 | break outer 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /_examples/pubsub/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "sync" 7 | "time" 8 | 9 | "github.com/rafaeljesus/rabbus" 10 | ) 11 | 12 | var ( 13 | rabbusDsn = "amqp://localhost:5672" 14 | timeout = time.After(time.Second * 3) 15 | wg sync.WaitGroup 16 | ) 17 | 18 | func main() { 19 | cbStateChangeFunc := func(name, from, to string) { 20 | // do something when state is changed 21 | } 22 | r, err := rabbus.New( 23 | rabbusDsn, 24 | rabbus.Durable(true), 25 | rabbus.Attempts(5), 26 | rabbus.Sleep(time.Second*2), 27 | rabbus.Threshold(3), 28 | rabbus.OnStateChange(cbStateChangeFunc), 29 | ) 30 | if err != nil { 31 | log.Fatalf("Failed to init rabbus connection %s", err) 32 | return 33 | } 34 | 35 | defer func(r *rabbus.Rabbus) { 36 | if err := r.Close(); err != nil { 37 | log.Fatalf("Failed to close rabbus connection %s", err) 38 | } 39 | }(r) 40 | 41 | ctx, cancel := context.WithCancel(context.Background()) 42 | defer cancel() 43 | 44 | go r.Run(ctx) 45 | 46 | messages, err := r.Listen(rabbus.ListenConfig{ 47 | Exchange: "pubsub_test_ex", 48 | Kind: "direct", 49 | Key: "pubsub_test_key", 50 | Queue: "pubsub_test_q", 51 | }) 52 | if err != nil { 53 | log.Fatalf("Failed to create listener %s", err) 54 | return 55 | } 56 | 57 | wg.Add(1) 58 | go func(messages chan rabbus.ConsumerMessage) { 59 | for m := range messages { 60 | m.Ack(false) 61 | close(messages) 62 | wg.Done() 63 | log.Println("Message was consumed") 64 | } 65 | }(messages) 66 | 67 | msg := rabbus.Message{ 68 | Exchange: "pubsub_test_ex", 69 | Kind: "direct", 70 | Key: "pubsub_test_key", 71 | Payload: []byte(`foo`), 72 | DeliveryMode: rabbus.Persistent, 73 | } 74 | 75 | r.EmitAsync() <- msg 76 | 77 | outer: 78 | for { 79 | select { 80 | case <-r.EmitOk(): 81 | log.Println("Message was sent") 82 | wg.Wait() 83 | log.Println("Done!") 84 | break outer 85 | case err := <-r.EmitErr(): 86 | log.Fatalf("Failed to send message %s", err) 87 | break outer 88 | case <-timeout: 89 | log.Fatal("Timeout error during send message") 90 | break outer 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /bind_args.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "github.com/streadway/amqp" 5 | ) 6 | 7 | // BindArgs is the wrapper for AMQP Table class to set common queue bind values 8 | type BindArgs struct { 9 | args amqp.Table 10 | } 11 | 12 | // NewBindArgs creates new queue bind values builder 13 | func NewBindArgs() *BindArgs { 14 | return &BindArgs{args: make(amqp.Table)} 15 | } 16 | 17 | // With sets the value by name 18 | func (a *BindArgs) With(name string, value interface{}) *BindArgs { 19 | a.args[name] = value 20 | return a 21 | } 22 | -------------------------------------------------------------------------------- /bind_args_test.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import "testing" 4 | 5 | func TestBindArgs_With(t *testing.T) { 6 | a := NewBindArgs().With("foo", "bar") 7 | val, found := a.args["foo"] 8 | 9 | if !found { 10 | t.Fatalf("Key is not found in Bind Args: %q", "foo") 11 | } 12 | 13 | if val != "bar" { 14 | t.Fatalf("Key %q value does not match expected: got %q instead of %q", "foo", val, "bar") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /consumer_message.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/streadway/amqp" 7 | ) 8 | 9 | type ( 10 | // ConsumerMessage captures the fields for a previously delivered message resident in a queue 11 | // to be delivered by the server to a consumer. 12 | ConsumerMessage struct { 13 | delivery amqp.Delivery 14 | ContentType string 15 | ContentEncoding string 16 | // DeliveryMode queue implementation use, non-persistent (1) or persistent (2) 17 | DeliveryMode uint8 18 | // Priority queue implementation use, 0 to 9 19 | Priority uint8 20 | // CorrelationId application use, correlation identifier 21 | CorrelationId string 22 | // ReplyTo application use, address to to reply to (ex: RPC) 23 | ReplyTo string 24 | // Expiration implementation use, message expiration spec 25 | Expiration string 26 | // MessageId application use, message identifier 27 | MessageId string 28 | // Timestamp application use, message timestamp 29 | Timestamp time.Time 30 | // Type application use, message type name 31 | Type string 32 | // ConsumerTag valid only with Channel.Consume 33 | ConsumerTag string 34 | // MessageCount valid only with Channel.Get 35 | MessageCount uint32 36 | DeliveryTag uint64 37 | Redelivered bool 38 | Exchange string 39 | // Headers application or header exchange table 40 | Headers map[string]interface{} 41 | // Key basic.publish routing key 42 | Key string 43 | Body []byte 44 | } 45 | ) 46 | 47 | func newConsumerMessage(m amqp.Delivery) ConsumerMessage { 48 | return ConsumerMessage{ 49 | delivery: m, 50 | ContentType: m.ContentType, 51 | ContentEncoding: m.ContentEncoding, 52 | DeliveryMode: m.DeliveryMode, 53 | Priority: m.Priority, 54 | CorrelationId: m.CorrelationId, 55 | ReplyTo: m.ReplyTo, 56 | Expiration: m.Expiration, 57 | Timestamp: m.Timestamp, 58 | Type: m.Type, 59 | ConsumerTag: m.ConsumerTag, 60 | MessageCount: m.MessageCount, 61 | DeliveryTag: m.DeliveryTag, 62 | Redelivered: m.Redelivered, 63 | Exchange: m.Exchange, 64 | Headers: m.Headers, 65 | Key: m.RoutingKey, 66 | Body: m.Body, 67 | } 68 | } 69 | 70 | // Ack delegates an acknowledgement through the Acknowledger interface that the client or server has finished work on a delivery. 71 | // All deliveries in AMQP must be acknowledged. If you called Channel.Consume with autoAck true then the server will be automatically ack each message and this method should not be called. Otherwise, you must call Delivery.Ack after you have successfully processed this delivery. 72 | // When multiple is true, this delivery and all prior unacknowledged deliveries on the same channel will be acknowledged. This is useful for batch processing of deliveries. 73 | // An error will indicate that the acknowledge could not be delivered to the channel it was sent from. 74 | // Either Delivery.Ack, Delivery.Reject or Delivery.Nack must be called for every delivery that is not automatically acknowledged. 75 | func (cm ConsumerMessage) Ack(multiple bool) error { 76 | return cm.delivery.Ack(multiple) 77 | } 78 | 79 | // Nack negatively acknowledge the delivery of message(s) identified by the delivery tag from either the client or server. 80 | // When multiple is true, nack messages up to and including delivered messages up until the delivery tag delivered on the same channel. 81 | // When requeue is true, request the server to deliver this message to a different consumer. If it is not possible or requeue is false, the message will be dropped or delivered to a server configured dead-letter queue. 82 | // This method must not be used to select or requeue messages the client wishes not to handle, rather it is to inform the server that the client is incapable of handling this message at this time. 83 | // Either Delivery.Ack, Delivery.Reject or Delivery.Nack must be called for every delivery that is not automatically acknowledged. 84 | func (cm ConsumerMessage) Nack(multiple, requeue bool) error { 85 | return cm.delivery.Nack(multiple, requeue) 86 | } 87 | 88 | // Reject delegates a negatively acknowledgement through the Acknowledger interface. 89 | // When requeue is true, queue this message to be delivered to a consumer on a different channel. When requeue is false or the server is unable to queue this message, it will be dropped. 90 | // If you are batch processing deliveries, and your server supports it, prefer Delivery.Nack. 91 | // Either Delivery.Ack, Delivery.Reject or Delivery.Nack must be called for every delivery that is not automatically acknowledged. 92 | func (cm ConsumerMessage) Reject(requeue bool) error { 93 | return cm.delivery.Reject(requeue) 94 | } 95 | -------------------------------------------------------------------------------- /consumer_message_test.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/streadway/amqp" 7 | ) 8 | 9 | func TestConsumerMessage(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | scenario string 14 | function func(*testing.T) 15 | }{ 16 | { 17 | "ack message", 18 | testAckMessage, 19 | }, 20 | { 21 | "nack message", 22 | testNackMessage, 23 | }, 24 | { 25 | "reject message", 26 | testRejectMessage, 27 | }, 28 | } 29 | 30 | for _, test := range tests { 31 | t.Run(test.scenario, func(t *testing.T) { 32 | test.function(t) 33 | }) 34 | } 35 | } 36 | 37 | func testAckMessage(t *testing.T) { 38 | ack := &acknowledger{} 39 | d := amqp.Delivery{Acknowledger: ack} 40 | cm := newConsumerMessage(d) 41 | cm.Ack(false) 42 | 43 | if !ack.ackInvoked { 44 | t.Fatal("expected acknowledger.Ack to be invoked") 45 | } 46 | } 47 | 48 | func testNackMessage(t *testing.T) { 49 | ack := &acknowledger{} 50 | d := amqp.Delivery{Acknowledger: ack} 51 | cm := newConsumerMessage(d) 52 | cm.Nack(false, false) 53 | 54 | if !ack.nackInvoked { 55 | t.Fatal("expected acknowledger.Nack to be invoked") 56 | } 57 | } 58 | 59 | func testRejectMessage(t *testing.T) { 60 | ack := &acknowledger{} 61 | d := amqp.Delivery{Acknowledger: ack} 62 | cm := newConsumerMessage(d) 63 | cm.Reject(false) 64 | 65 | if !ack.rejectInvoked { 66 | t.Fatal("expected acknowledger.Reject to be invoked") 67 | } 68 | } 69 | 70 | type acknowledger struct { 71 | ackInvoked, nackInvoked, rejectInvoked bool 72 | } 73 | 74 | func (a *acknowledger) Ack(tag uint64, multiple bool) error { 75 | a.ackInvoked = true 76 | return nil 77 | } 78 | 79 | func (a *acknowledger) Nack(tag uint64, multiple bool, requeue bool) error { 80 | a.nackInvoked = true 81 | return nil 82 | } 83 | 84 | func (a *acknowledger) Reject(tag uint64, requeue bool) error { 85 | a.rejectInvoked = true 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /declare_args.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/streadway/amqp" 7 | ) 8 | 9 | const ( 10 | messageTTL = "x-message-ttl" 11 | ) 12 | 13 | // DeclareArgs is the queue declaration values builder 14 | type DeclareArgs struct { 15 | args amqp.Table 16 | } 17 | 18 | // NewDeclareArgs creates new queue declaration values builder 19 | func NewDeclareArgs() *DeclareArgs { 20 | return &DeclareArgs{args: make(amqp.Table)} 21 | } 22 | 23 | // WithMessageTTL sets Queue message TTL. See details at https://www.rabbitmq.com/ttl.html#message-ttl-using-x-args 24 | func (a *DeclareArgs) WithMessageTTL(d time.Duration) *DeclareArgs { 25 | // RabbitMQ requires time in milliseconds and duration is in Nanosecond 26 | return a.With(messageTTL, int64(d/time.Millisecond)) 27 | } 28 | 29 | // With sets the value by name 30 | func (a *DeclareArgs) With(name string, value interface{}) *DeclareArgs { 31 | a.args[name] = value 32 | return a 33 | } 34 | -------------------------------------------------------------------------------- /declare_args_test.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestDeclareArgs_With(t *testing.T) { 9 | a := NewDeclareArgs().With("foo", "bar") 10 | val, found := a.args["foo"] 11 | 12 | if !found { 13 | t.Fatalf("Key is not found in Declare Args: %q", "foo") 14 | } 15 | 16 | if val != "bar" { 17 | t.Fatalf("Key %q value does not match expected: got %q instead of %q", "foo", val, "bar") 18 | } 19 | } 20 | 21 | func TestDeclareArgs_WithMessageTTL(t *testing.T) { 22 | a := NewDeclareArgs().WithMessageTTL(60 * time.Second) 23 | val, found := a.args[messageTTL] 24 | 25 | if !found { 26 | t.Fatalf("Key is not found in Declare Args: %q", messageTTL) 27 | } 28 | 29 | if val != int64(60000) { 30 | t.Fatalf("Key %q value does not match expected: got %d instead of %d", messageTTL, val, int64(60000)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | rabbit: 4 | image: rabbitmq:3.6-management-alpine 5 | ports: 6 | - "5672" 7 | - "15672" 8 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrMissingExchange is returned when exchange name is not passed as parameter. 9 | ErrMissingExchange = errors.New("Missing field exchange") 10 | // ErrMissingKind is returned when exchange type is not passed as parameter. 11 | ErrMissingKind = errors.New("Missing field kind") 12 | // ErrMissingQueue is returned when queue name is not passed as parameter. 13 | ErrMissingQueue = errors.New("Missing field queue") 14 | // ErrMissingHandler is returned when function handler is not passed as parameter. 15 | ErrMissingHandler = errors.New("Missing field handler") 16 | // ErrUnsupportedArguments is returned when more than the permitted arguments is passed to a function. 17 | ErrUnsupportedArguments = errors.New("Unsupported arguments size") 18 | ) 19 | -------------------------------------------------------------------------------- /integration/rabbus_integration_test.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "sync" 12 | "testing" 13 | "time" 14 | 15 | "github.com/rafaeljesus/rabbus" 16 | ) 17 | 18 | const ( 19 | amqpDsnEnv = "AMQP_DSN" 20 | amqpManagementEnv = "AMQP_MANAGEMENT_PORT" 21 | ) 22 | 23 | var ( 24 | amqpDsn string 25 | timeout = time.After(3 * time.Second) 26 | ) 27 | 28 | func TestRabbus(t *testing.T) { 29 | if os.Getenv(amqpDsnEnv) == "" { 30 | t.SkipNow() 31 | } 32 | 33 | amqpDsn = os.Getenv(amqpDsnEnv) 34 | 35 | tests := []struct { 36 | scenario string 37 | function func(*testing.T) 38 | }{ 39 | { 40 | "rabbus publish subscribe", 41 | testRabbusPublishSubscribe, 42 | }, 43 | } 44 | 45 | for _, test := range tests { 46 | t.Run(test.scenario, func(t *testing.T) { 47 | test.function(t) 48 | }) 49 | } 50 | } 51 | 52 | func BenchmarkRabbus(b *testing.B) { 53 | if os.Getenv(amqpDsnEnv) == "" { 54 | b.SkipNow() 55 | } 56 | 57 | tests := []struct { 58 | scenario string 59 | function func(*testing.B) 60 | }{ 61 | { 62 | "rabbus emit async benchmark", 63 | benchmarkEmitAsync, 64 | }, 65 | } 66 | 67 | for _, test := range tests { 68 | b.Run(test.scenario, func(b *testing.B) { 69 | test.function(b) 70 | }) 71 | } 72 | } 73 | 74 | func testRabbusPublishSubscribe(t *testing.T) { 75 | r, err := rabbus.New( 76 | amqpDsn, 77 | rabbus.Durable(true), 78 | rabbus.Attempts(5), 79 | rabbus.BreakerTimeout(time.Second*2), 80 | ) 81 | if err != nil { 82 | t.Fatalf("expected to init rabbus %s", err) 83 | } 84 | 85 | defer func(r *rabbus.Rabbus) { 86 | if err = r.Close(); err != nil { 87 | t.Errorf("expected to close rabbus %s", err) 88 | } 89 | }(r) 90 | 91 | ctx, cancel := context.WithCancel(context.Background()) 92 | defer cancel() 93 | 94 | go r.Run(ctx) 95 | 96 | messages, err := r.Listen(rabbus.ListenConfig{ 97 | Exchange: "test_ex", 98 | Kind: rabbus.ExchangeDirect, 99 | Key: "test_key", 100 | Queue: "test_q", 101 | }) 102 | if err != nil { 103 | t.Fatalf("expected to listen message %s", err) 104 | } 105 | 106 | var wg sync.WaitGroup 107 | wg.Add(1) 108 | 109 | go func(messages chan rabbus.ConsumerMessage) { 110 | for m := range messages { 111 | defer wg.Done() 112 | close(messages) 113 | m.Ack(false) 114 | } 115 | }(messages) 116 | 117 | msg := rabbus.Message{ 118 | Exchange: "test_ex", 119 | Kind: rabbus.ExchangeDirect, 120 | Key: "test_key", 121 | Payload: []byte(`foo`), 122 | DeliveryMode: rabbus.Persistent, 123 | } 124 | 125 | r.EmitAsync() <- msg 126 | 127 | outer: 128 | for { 129 | select { 130 | case <-r.EmitOk(): 131 | wg.Wait() 132 | break outer 133 | case <-r.EmitErr(): 134 | t.Fatalf("expected to emit message") 135 | break outer 136 | case <-timeout: 137 | t.Fatalf("parallel.Run() failed, got timeout error") 138 | break outer 139 | } 140 | } 141 | } 142 | 143 | func benchmarkEmitAsync(b *testing.B) { 144 | r, err := rabbus.New( 145 | amqpDsn, 146 | rabbus.Attempts(1), 147 | rabbus.BreakerTimeout(time.Second*2), 148 | ) 149 | if err != nil { 150 | b.Fatalf("expected to init rabbus %s", err) 151 | } 152 | 153 | defer func(r *rabbus.Rabbus) { 154 | if err := r.Close(); err != nil { 155 | b.Fatalf("expected to close rabbus %s", err) 156 | } 157 | }(r) 158 | 159 | ctx, cancel := context.WithCancel(context.Background()) 160 | defer cancel() 161 | 162 | go r.Run(ctx) 163 | 164 | var wg sync.WaitGroup 165 | wg.Add(b.N) 166 | 167 | go func(r *rabbus.Rabbus) { 168 | for { 169 | select { 170 | case _, ok := <-r.EmitOk(): 171 | if ok { 172 | wg.Done() 173 | } 174 | case _, ok := <-r.EmitErr(): 175 | if ok { 176 | b.Fatalf("expected to emit message, receive error: %v", err) 177 | } 178 | } 179 | } 180 | }(r) 181 | 182 | for n := 0; n < b.N; n++ { 183 | msg := rabbus.Message{ 184 | Exchange: "test_bench_ex" + strconv.Itoa(n%10), 185 | Kind: rabbus.ExchangeDirect, 186 | Key: "test_key", 187 | Payload: []byte(`foo`), 188 | DeliveryMode: rabbus.Persistent, 189 | } 190 | 191 | r.EmitAsync() <- msg 192 | } 193 | 194 | wg.Wait() 195 | } 196 | 197 | func TestPublishDisconnect(t *testing.T) { 198 | if os.Getenv(amqpDsnEnv) == "" || os.Getenv(amqpManagementEnv) == "" { 199 | t.SkipNow() 200 | } 201 | 202 | amqpDsn = os.Getenv(amqpDsnEnv) 203 | amqpManagement := os.Getenv(amqpManagementEnv) 204 | 205 | r, err := rabbus.New( 206 | amqpDsn, 207 | rabbus.Durable(true), 208 | rabbus.Attempts(2), 209 | rabbus.BreakerTimeout(time.Millisecond*2), 210 | ) 211 | if err != nil { 212 | t.Fatalf("expected to init rabbus %s", err) 213 | } 214 | 215 | defer func(r *rabbus.Rabbus) { 216 | if err = r.Close(); err != nil { 217 | t.Errorf("expected to close rabbus %s", err) 218 | } 219 | }(r) 220 | 221 | ctx, cancel := context.WithCancel(context.Background()) 222 | defer cancel() 223 | 224 | go r.Run(ctx) 225 | 226 | msg := rabbus.Message{ 227 | Exchange: "test_ex_kill", 228 | Kind: rabbus.ExchangeDirect, 229 | Key: "test_key", 230 | Payload: []byte(`foo`), 231 | DeliveryMode: rabbus.Persistent, 232 | } 233 | 234 | r.EmitAsync() <- msg 235 | select { 236 | case <-r.EmitOk(): 237 | 238 | case <-r.EmitErr(): 239 | t.Error("expected to emit message") 240 | case <-timeout: 241 | t.Error("parallel.Run() failed, got timeout error") 242 | } 243 | killConnections(t, amqpManagement) 244 | 245 | r.EmitAsync() <- msg 246 | select { 247 | case <-r.EmitOk(): 248 | 249 | case <-r.EmitErr(): 250 | t.Error("expected to emit message") 251 | case <-time.After(5 * time.Second): 252 | t.Error("parallel.Run() failed, got timeout error") 253 | } 254 | 255 | } 256 | 257 | type amqpConnection struct { 258 | Name string `json:"name"` 259 | } 260 | 261 | // return all connections opened in rabbitmq via the management API 262 | func getConnections(amqpManagement string) ([]amqpConnection, error) { 263 | client := &http.Client{} 264 | url := fmt.Sprintf("%s/connections", amqpManagement) 265 | 266 | req, err := http.NewRequest("GET", url, nil) 267 | if err != nil { 268 | return nil, err 269 | } 270 | req.SetBasicAuth("guest", "guest") 271 | resp, err := client.Do(req) 272 | if err != nil { 273 | return nil, err 274 | } 275 | defer resp.Body.Close() 276 | if resp.StatusCode != http.StatusOK { 277 | return nil, fmt.Errorf("Non 200 response: %s", resp.Status) 278 | } 279 | b, err := ioutil.ReadAll(resp.Body) 280 | if err != nil { 281 | return nil, err 282 | } 283 | 284 | r := make([]amqpConnection, 0) 285 | err = json.Unmarshal(b, &r) 286 | if err != nil { 287 | return nil, err 288 | } 289 | return r, nil 290 | } 291 | 292 | // close one connection 293 | func killConnection(connection amqpConnection, amqpManagement string) error { 294 | client := &http.Client{} 295 | url := fmt.Sprintf("%s/connections/%s", amqpManagement, connection.Name) 296 | 297 | req, err := http.NewRequest("DELETE", url, nil) 298 | if err != nil { 299 | return err 300 | } 301 | req.SetBasicAuth("guest", "guest") 302 | resp, err := client.Do(req) 303 | if err != nil { 304 | return err 305 | } 306 | defer resp.Body.Close() 307 | if resp.StatusCode != http.StatusNoContent { 308 | return fmt.Errorf("deletion of connection failed, status: %s", resp.Status) 309 | } 310 | return nil 311 | } 312 | 313 | // close all open connections to the rabbitmq via the management api 314 | func killConnections(t *testing.T, amqpManagement string) { 315 | conns := make(chan []amqpConnection) 316 | go func() { 317 | for { 318 | connections, err := getConnections(amqpManagement) 319 | if err != nil { 320 | t.Error(err) 321 | } 322 | if len(connections) >= 1 { 323 | conns <- connections 324 | break //exit the loop 325 | } 326 | //the rabbitmq api is a bit slow to update, we have to wait a bit 327 | time.Sleep(time.Second) 328 | } 329 | }() 330 | select { 331 | case connections := <-conns: 332 | for _, c := range connections { 333 | t.Log(c.Name) 334 | if err := killConnection(c, amqpManagement); err != nil { 335 | t.Errorf("impossible to kill connection (%s): %s", c.Name, err) 336 | } 337 | } 338 | case <-time.After(time.Second * 10): 339 | t.Error("timeout for killing connection reached") 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /internal/amqp/amqp.go: -------------------------------------------------------------------------------- 1 | package amqp 2 | 3 | import "github.com/streadway/amqp" 4 | 5 | type ( 6 | // AMQP interpret (implement) AMQP interface definition 7 | AMQP struct { 8 | conn *amqp.Connection 9 | ch *amqp.Channel 10 | passiveExchange bool 11 | } 12 | ) 13 | 14 | // New returns a new AMQP configured, or returning an non-nil err 15 | // if an error occurred while creating connection or channel. 16 | func New(dsn string, pex bool) (*AMQP, error) { 17 | conn, ch, err := createConnAndChan(dsn) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return &AMQP{ 23 | conn: conn, 24 | ch: ch, 25 | passiveExchange: pex, 26 | }, nil 27 | } 28 | 29 | // Publish wraps amqp.Publish method 30 | func (ai *AMQP) Publish(exchange, key string, opts amqp.Publishing) error { 31 | return ai.ch.Publish(exchange, key, false, false, opts) 32 | } 33 | 34 | // CreateConsumer creates a amqp consumer. Most interesting declare args are: 35 | func (ai *AMQP) CreateConsumer(exchange, key, kind, queue string, durable bool, declareArgs, bindArgs amqp.Table) (<-chan amqp.Delivery, error) { 36 | if err := ai.WithExchange(exchange, kind, durable); err != nil { 37 | return nil, err 38 | } 39 | 40 | q, err := ai.ch.QueueDeclare(queue, durable, false, false, false, declareArgs) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | if err := ai.ch.QueueBind(q.Name, key, exchange, false, bindArgs); err != nil { 46 | return nil, err 47 | } 48 | 49 | return ai.ch.Consume(q.Name, "", false, false, false, false, nil) 50 | } 51 | 52 | // WithExchange creates a amqp exchange 53 | func (ai *AMQP) WithExchange(exchange, kind string, durable bool) error { 54 | if ai.passiveExchange { 55 | return ai.ch.ExchangeDeclarePassive(exchange, kind, durable, false, false, false, nil) 56 | } 57 | 58 | return ai.ch.ExchangeDeclare(exchange, kind, durable, false, false, false, nil) 59 | } 60 | 61 | // WithQos wrapper over amqp.Qos method 62 | func (ai *AMQP) WithQos(count, size int, global bool) error { 63 | return ai.ch.Qos(count, size, global) 64 | } 65 | 66 | // NotifyClose wrapper over notifyClose method 67 | func (ai *AMQP) NotifyClose(c chan *amqp.Error) chan *amqp.Error { 68 | return ai.conn.NotifyClose(c) 69 | } 70 | 71 | // Close closes the running amqp connection and channel 72 | func (ai *AMQP) Close() error { 73 | if err := ai.ch.Close(); err != nil { 74 | return err 75 | } 76 | 77 | if ai.conn != nil { 78 | return ai.conn.Close() 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func createConnAndChan(dsn string) (*amqp.Connection, *amqp.Channel, error) { 85 | conn, err := amqp.Dial(dsn) 86 | if err != nil { 87 | return nil, nil, err 88 | } 89 | 90 | ch, err := conn.Channel() 91 | if err != nil { 92 | return nil, nil, err 93 | } 94 | 95 | return conn, ch, nil 96 | } 97 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | // Option represents an option you can pass to New. 9 | // See the documentation for the individual options. 10 | type Option func(*Rabbus) error 11 | 12 | // Durable indicates of the queue will survive broker restarts. Default to true. 13 | func Durable(durable bool) Option { 14 | return func(r *Rabbus) error { 15 | r.config.durable = durable 16 | return nil 17 | } 18 | } 19 | 20 | // PassiveExchange forces passive connection with all exchanges using 21 | // amqp's ExchangeDeclarePassive instead the default ExchangeDeclare 22 | func PassiveExchange(isExchangePassive bool) Option { 23 | return func(r *Rabbus) error { 24 | r.config.isExchangePassive = isExchangePassive 25 | return nil 26 | } 27 | } 28 | 29 | // PrefetchCount limit the number of unacknowledged messages. 30 | func PrefetchCount(count int) Option { 31 | return func(r *Rabbus) error { 32 | r.config.qos.prefetchCount = count 33 | return nil 34 | } 35 | } 36 | 37 | // PrefetchSize when greater than zero, the server will try to keep at least 38 | // that many bytes of deliveries flushed to the network before receiving 39 | // acknowledgments from the consumers. 40 | func PrefetchSize(size int) Option { 41 | return func(r *Rabbus) error { 42 | r.config.qos.prefetchSize = size 43 | return nil 44 | } 45 | } 46 | 47 | // QosGlobal when global is true, these Qos settings apply to all existing and future 48 | // consumers on all channels on the same connection. When false, the Channel.Qos 49 | // settings will apply to all existing and future consumers on this channel. 50 | // RabbitMQ does not implement the global flag. 51 | func QosGlobal(global bool) Option { 52 | return func(r *Rabbus) error { 53 | r.config.qos.global = global 54 | return nil 55 | } 56 | } 57 | 58 | // Attempts is the max number of retries on broker outages. 59 | func Attempts(attempts int) Option { 60 | return func(r *Rabbus) error { 61 | r.config.retryCfg.attempts = attempts 62 | return nil 63 | } 64 | } 65 | 66 | // Sleep is the sleep time of the retry mechanism. 67 | func Sleep(sleep time.Duration) Option { 68 | return func(r *Rabbus) error { 69 | if sleep == 0 { 70 | r.config.retryCfg.reconnectSleep = time.Second * 10 71 | } 72 | r.config.retryCfg.sleep = sleep 73 | return nil 74 | } 75 | } 76 | 77 | // BreakerInterval is the cyclic period of the closed state for CircuitBreaker to clear the internal counts, 78 | // If Interval is 0, CircuitBreaker doesn't clear the internal counts during the closed state. 79 | func BreakerInterval(interval time.Duration) Option { 80 | return func(r *Rabbus) error { 81 | r.config.breaker.interval = interval 82 | return nil 83 | } 84 | } 85 | 86 | // BreakerTimeout is the period of the open state, after which the state of CircuitBreaker becomes half-open. 87 | // If Timeout is 0, the timeout value of CircuitBreaker is set to 60 seconds. 88 | func BreakerTimeout(timeout time.Duration) Option { 89 | return func(r *Rabbus) error { 90 | r.config.breaker.timeout = timeout 91 | return nil 92 | } 93 | } 94 | 95 | // Threshold when a threshold of failures has been reached, future calls to the broker will not run. 96 | // During this state, the circuit breaker will periodically allow the calls to run and, if it is successful, 97 | // will start running the function again. Default value is 5. 98 | func Threshold(threshold uint32) Option { 99 | return func(r *Rabbus) error { 100 | if threshold == 0 { 101 | threshold = 5 102 | } 103 | r.config.breaker.threshold = threshold 104 | return nil 105 | } 106 | } 107 | 108 | // OnStateChange is called whenever the state of CircuitBreaker changes. 109 | func OnStateChange(fn OnStateChangeFunc) Option { 110 | return func(r *Rabbus) error { 111 | r.config.breaker.onStateChange = fn 112 | return nil 113 | } 114 | } 115 | 116 | // AMQPProvider expose a interface for interacting with amqp broker 117 | func AMQPProvider(provider AMQP) Option { 118 | return func(r *Rabbus) error { 119 | if provider != nil { 120 | r.AMQP = provider 121 | return nil 122 | } 123 | return errors.New("unexpected amqp provider") 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /rabbus.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | 9 | amqpWrap "github.com/rafaeljesus/rabbus/internal/amqp" 10 | 11 | "github.com/rafaeljesus/retry-go" 12 | "github.com/sony/gobreaker" 13 | "github.com/streadway/amqp" 14 | ) 15 | 16 | const ( 17 | // Transient means higher throughput but messages will not be restored on broker restart. 18 | Transient uint8 = 1 19 | // Persistent messages will be restored to durable queues and lost on non-durable queues during server restart. 20 | Persistent uint8 = 2 21 | // ContentTypeJSON define json content type. 22 | ContentTypeJSON = "application/json" 23 | // ContentTypePlain define plain text content type. 24 | ContentTypePlain = "plain/text" 25 | // ExchangeDirect indicates the exchange is of direct type. 26 | ExchangeDirect = "direct" 27 | // ExchangeFanout indicates the exchange is of fanout type. 28 | ExchangeFanout = "fanout" 29 | // ExchangeTopic indicates the exchange is of topic type. 30 | ExchangeTopic = "topic" 31 | 32 | contentEncoding = "UTF-8" 33 | ) 34 | 35 | type ( 36 | // OnStateChangeFunc is the callback function when circuit breaker state changes. 37 | OnStateChangeFunc func(name, from, to string) 38 | 39 | // Message carries fields for sending messages. 40 | Message struct { 41 | // Exchange the exchange name. 42 | Exchange string 43 | // Kind the exchange type. 44 | Kind string 45 | // Key the routing key name. 46 | Key string 47 | // Payload the message payload. 48 | Payload []byte 49 | // DeliveryMode indicates if the is Persistent or Transient. 50 | DeliveryMode uint8 51 | // ContentType the message content-type. 52 | ContentType string 53 | // Headers the message application headers 54 | Headers map[string]interface{} 55 | // ContentEncoding the message encoding. 56 | ContentEncoding string 57 | } 58 | 59 | // ListenConfig carries fields for listening messages. 60 | ListenConfig struct { 61 | // Exchange the exchange name. 62 | Exchange string 63 | // Kind the exchange type. 64 | Kind string 65 | // Key the routing key name. 66 | Key string 67 | // PassiveExchange determines a passive exchange connection it uses 68 | // amqp's ExchangeDeclarePassive instead the default ExchangeDeclare 69 | PassiveExchange bool 70 | // Queue the queue name 71 | Queue string 72 | // DeclareArgs is a list of arguments accepted for when declaring the queue. 73 | // See https://www.rabbitmq.com/queues.html#optional-arguments for more info. 74 | DeclareArgs *DeclareArgs 75 | // BindArgs is a list of arguments accepted for when binding the exchange to the queue 76 | BindArgs *BindArgs 77 | } 78 | 79 | // Delivery wraps amqp.Delivery struct 80 | Delivery struct { 81 | amqp.Delivery 82 | } 83 | 84 | // Rabbus interpret (implement) Rabbus interface definition 85 | Rabbus struct { 86 | AMQP 87 | mu sync.RWMutex 88 | breaker *gobreaker.CircuitBreaker 89 | emit chan Message 90 | emitErr chan error 91 | emitOk chan struct{} 92 | reconn chan struct{} 93 | exDeclared map[string]struct{} 94 | config 95 | conDeclared int // conDeclared is a counter for the declared consumers 96 | } 97 | 98 | // AMQP exposes a interface for interacting with AMQP broker 99 | AMQP interface { 100 | // Publish wraps amqp.Publish method 101 | Publish(exchange, key string, opts amqp.Publishing) error 102 | // CreateConsumer creates a amqp consumer 103 | CreateConsumer(exchange, key, kind, queue string, durable bool, declareArgs, bindArgs amqp.Table) (<-chan amqp.Delivery, error) 104 | // WithExchange creates a amqp exchange 105 | WithExchange(exchange, kind string, durable bool) error 106 | // WithQos wrapper over amqp.Qos method 107 | WithQos(count, size int, global bool) error 108 | // NotifyClose wrapper over notifyClose method 109 | NotifyClose(c chan *amqp.Error) chan *amqp.Error 110 | // Close closes the running amqp connection and channel 111 | Close() error 112 | } 113 | 114 | // Emitter exposes a interface for publishing messages to AMQP broker 115 | Emitter interface { 116 | // EmitAsync emits a message to RabbitMQ, but does not wait for the response from broker. 117 | EmitAsync() chan<- Message 118 | // EmitErr returns an error if encoding payload fails, or if after circuit breaker is open or retries attempts exceed. 119 | EmitErr() <-chan error 120 | // EmitOk returns true when the message was sent. 121 | EmitOk() <-chan struct{} 122 | } 123 | 124 | config struct { 125 | dsn string 126 | durable bool 127 | isExchangePassive bool 128 | retryCfg 129 | breaker 130 | qos 131 | } 132 | 133 | retryCfg struct { 134 | attempts int 135 | sleep, reconnectSleep time.Duration 136 | } 137 | 138 | breaker struct { 139 | interval, timeout time.Duration 140 | threshold uint32 141 | onStateChange OnStateChangeFunc 142 | } 143 | 144 | qos struct { 145 | prefetchCount, prefetchSize int 146 | global bool 147 | } 148 | ) 149 | 150 | func (lc ListenConfig) validate() error { 151 | if lc.Exchange == "" { 152 | return ErrMissingExchange 153 | } 154 | 155 | if lc.Kind == "" { 156 | return ErrMissingKind 157 | } 158 | 159 | if lc.Queue == "" { 160 | return ErrMissingQueue 161 | } 162 | 163 | return nil 164 | } 165 | 166 | // New returns a new Rabbus configured with the 167 | // variables from the config parameter, or returning an non-nil err 168 | // if an error occurred while creating connection and channel. 169 | func New(dsn string, options ...Option) (*Rabbus, error) { 170 | r := &Rabbus{ 171 | emit: make(chan Message), 172 | emitErr: make(chan error), 173 | emitOk: make(chan struct{}), 174 | reconn: make(chan struct{}), 175 | exDeclared: make(map[string]struct{}), 176 | } 177 | 178 | for _, o := range options { 179 | if err := o(r); err != nil { 180 | return nil, err 181 | } 182 | } 183 | 184 | if r.AMQP == nil { 185 | amqpWrapper, err := amqpWrap.New(dsn, r.config.isExchangePassive) 186 | if err != nil { 187 | return nil, err 188 | } 189 | r.AMQP = amqpWrapper 190 | } 191 | 192 | if err := r.WithQos( 193 | r.config.qos.prefetchCount, 194 | r.config.qos.prefetchSize, 195 | r.config.qos.global, 196 | ); err != nil { 197 | return nil, err 198 | } 199 | 200 | r.config.dsn = dsn 201 | r.breaker = gobreaker.NewCircuitBreaker(newBreakerSettings(r.config)) 202 | 203 | return r, nil 204 | } 205 | 206 | // Run starts rabbus channels for emitting and listening for amqp connection close 207 | // returns ctx error in case of any. 208 | func (r *Rabbus) Run(ctx context.Context) error { 209 | notifyClose := r.NotifyClose(make(chan *amqp.Error)) 210 | 211 | for { 212 | select { 213 | case m, ok := <-r.emit: 214 | if !ok { 215 | return errors.New("unexpected close of emit channel") 216 | } 217 | 218 | r.produce(m) 219 | 220 | case err := <-notifyClose: 221 | if err == nil { 222 | // "… on a graceful close, no error will be sent." 223 | return nil 224 | } 225 | 226 | r.handleAMQPClose(err) 227 | 228 | // We have reconnected, so we need a new NotifyClose again. 229 | notifyClose = r.NotifyClose(make(chan *amqp.Error)) 230 | 231 | case <-ctx.Done(): 232 | return ctx.Err() 233 | } 234 | } 235 | } 236 | 237 | // EmitAsync emits a message to RabbitMQ, but does not wait for the response from broker. 238 | func (r *Rabbus) EmitAsync() chan<- Message { return r.emit } 239 | 240 | // EmitErr returns an error if encoding payload fails, or if after circuit breaker is open or retries attempts exceed. 241 | func (r *Rabbus) EmitErr() <-chan error { return r.emitErr } 242 | 243 | // EmitOk returns true when the message was sent. 244 | func (r *Rabbus) EmitOk() <-chan struct{} { return r.emitOk } 245 | 246 | // Listen to a message from RabbitMQ, returns 247 | // an error if exchange, queue name and function handler not passed or if an error occurred while creating 248 | // amqp consumer. 249 | func (r *Rabbus) Listen(c ListenConfig) (chan ConsumerMessage, error) { 250 | if err := c.validate(); err != nil { 251 | return nil, err 252 | } 253 | 254 | if c.DeclareArgs == nil { 255 | c.DeclareArgs = NewDeclareArgs() 256 | } 257 | 258 | if c.BindArgs == nil { 259 | c.BindArgs = NewBindArgs() 260 | } 261 | 262 | msgs, err := r.CreateConsumer(c.Exchange, c.Key, c.Kind, c.Queue, r.config.durable, c.DeclareArgs.args, c.BindArgs.args) 263 | if err != nil { 264 | return nil, err 265 | } 266 | 267 | r.mu.Lock() 268 | r.conDeclared++ // increase the declared consumers counter 269 | r.exDeclared[c.Exchange] = struct{}{} 270 | r.mu.Unlock() 271 | 272 | messages := make(chan ConsumerMessage, 256) 273 | go r.wrapMessage(c, msgs, messages) 274 | go r.listenReconnect(c, messages) 275 | 276 | return messages, nil 277 | } 278 | 279 | // Close channels and attempt to close channel and connection. 280 | func (r *Rabbus) Close() error { 281 | err := r.AMQP.Close() 282 | close(r.emit) 283 | close(r.emitOk) 284 | close(r.emitErr) 285 | close(r.reconn) 286 | return err 287 | } 288 | 289 | func (r *Rabbus) produce(m Message) { 290 | if _, ok := r.exDeclared[m.Exchange]; !ok { 291 | if err := r.WithExchange(m.Exchange, m.Kind, r.config.durable); err != nil { 292 | r.emitErr <- err 293 | return 294 | } 295 | r.exDeclared[m.Exchange] = struct{}{} 296 | } 297 | 298 | if m.ContentType == "" { 299 | m.ContentType = ContentTypeJSON 300 | } 301 | 302 | if m.DeliveryMode == 0 { 303 | m.DeliveryMode = Persistent 304 | } 305 | 306 | if m.ContentEncoding == "" { 307 | m.ContentEncoding = contentEncoding 308 | } 309 | 310 | opts := amqp.Publishing{ 311 | Headers: amqp.Table(m.Headers), 312 | ContentType: m.ContentType, 313 | ContentEncoding: m.ContentEncoding, 314 | DeliveryMode: m.DeliveryMode, 315 | Timestamp: time.Now(), 316 | Body: m.Payload, 317 | } 318 | 319 | if _, err := r.breaker.Execute(func() (interface{}, error) { 320 | return nil, retry.Do(func() error { 321 | return r.Publish(m.Exchange, m.Key, opts) 322 | }, r.config.retryCfg.attempts, r.config.retryCfg.sleep) 323 | }); err != nil { 324 | r.emitErr <- err 325 | return 326 | } 327 | 328 | r.emitOk <- struct{}{} 329 | } 330 | 331 | func (r *Rabbus) wrapMessage(c ListenConfig, sourceChan <-chan amqp.Delivery, targetChan chan ConsumerMessage) { 332 | for m := range sourceChan { 333 | targetChan <- newConsumerMessage(m) 334 | } 335 | } 336 | 337 | func (r *Rabbus) handleAMQPClose(err error) { 338 | for { 339 | time.Sleep(time.Second) 340 | aw, err := amqpWrap.New(r.config.dsn, r.config.isExchangePassive) 341 | if err != nil { 342 | continue 343 | } 344 | 345 | r.mu.Lock() 346 | r.AMQP = aw 347 | r.mu.Unlock() 348 | 349 | if err := r.WithQos( 350 | r.config.qos.prefetchCount, 351 | r.config.qos.prefetchSize, 352 | r.config.qos.global, 353 | ); err != nil { 354 | r.AMQP.Close() 355 | continue 356 | } 357 | 358 | for i := 1; i <= r.conDeclared; i++ { 359 | r.reconn <- struct{}{} 360 | } 361 | break 362 | } 363 | } 364 | 365 | func (r *Rabbus) listenReconnect(c ListenConfig, messages chan ConsumerMessage) { 366 | for range r.reconn { 367 | msgs, err := r.CreateConsumer(c.Exchange, c.Key, c.Kind, c.Queue, r.config.durable, c.DeclareArgs.args, c.BindArgs.args) 368 | if err != nil { 369 | continue 370 | } 371 | 372 | go r.wrapMessage(c, msgs, messages) 373 | go r.listenReconnect(c, messages) 374 | break 375 | } 376 | } 377 | 378 | func newBreakerSettings(c config) gobreaker.Settings { 379 | s := gobreaker.Settings{} 380 | s.Name = "rabbus-circuit-breaker" 381 | s.Interval = c.breaker.interval 382 | s.Timeout = c.breaker.timeout 383 | s.ReadyToTrip = func(counts gobreaker.Counts) bool { 384 | return counts.ConsecutiveFailures > c.breaker.threshold 385 | } 386 | 387 | if c.breaker.onStateChange != nil { 388 | s.OnStateChange = func(name string, from gobreaker.State, to gobreaker.State) { 389 | c.breaker.onStateChange(name, from.String(), to.String()) 390 | } 391 | } 392 | return s 393 | } 394 | -------------------------------------------------------------------------------- /rabbus_test.go: -------------------------------------------------------------------------------- 1 | package rabbus 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/streadway/amqp" 10 | ) 11 | 12 | var ( 13 | dsn = "amqp://path" 14 | timeout = time.After(3 * time.Second) 15 | errAmqp = errors.New("amqp error") 16 | ) 17 | 18 | func TestRabbus(t *testing.T) { 19 | t.Parallel() 20 | 21 | tests := []struct { 22 | scenario string 23 | function func(*testing.T) 24 | }{ 25 | { 26 | "create new rabbus specifying amqp provider", 27 | testCreateNewSpecifyingAmqpProvider, 28 | }, 29 | { 30 | "fail to create new rabbus when withQos returns error", 31 | testFailToCreateNewWhenWithQosReturnsError, 32 | }, 33 | { 34 | "validate rabbus listener", 35 | testValidateRabbusListener, 36 | }, 37 | { 38 | "create new rabbus listener", 39 | testCreateNewListener, 40 | }, 41 | { 42 | "fail to create new rabbus listener when create consumer returns error", 43 | testFailToCreateNewListenerWhenCreateConsumerReturnsError, 44 | }, 45 | { 46 | "emit async message", 47 | testEmitAsyncMessage, 48 | }, 49 | { 50 | "emit async message fail to declare exchange", 51 | testEmitAsyncMessageFailToDeclareExchange, 52 | }, 53 | { 54 | "emit async message fail to publish", 55 | testEmitAsyncMessageFailToPublish, 56 | }, 57 | { 58 | "emit async message ensure breaker", 59 | testEmitAsyncMessageEnsureBreaker, 60 | }, 61 | } 62 | 63 | for _, test := range tests { 64 | t.Run(test.scenario, func(t *testing.T) { 65 | test.function(t) 66 | }) 67 | } 68 | } 69 | 70 | func testCreateNewSpecifyingAmqpProvider(t *testing.T) { 71 | count := 1 72 | size := 10 73 | global := true 74 | provider := new(amqpMock) 75 | provider.withQosFn = func(c, s int, g bool) error { 76 | if count != c { 77 | t.Fatalf("unexpected prefetch count: %d", c) 78 | } 79 | if size != s { 80 | t.Fatalf("unexpected prefetch size: %d", s) 81 | } 82 | if global != g { 83 | t.Fatalf("unexpected global: %t", g) 84 | } 85 | return nil 86 | } 87 | _, err := New(dsn, PrefetchCount(count), PrefetchSize(size), QosGlobal(global), AMQPProvider(provider)) 88 | if err != nil { 89 | t.Fatalf("expected to create new rabbus, got %s", err) 90 | } 91 | 92 | if !provider.withQosInvoked { 93 | t.Fatal("expected provider.WithQos() to be invoked") 94 | } 95 | } 96 | 97 | func testFailToCreateNewWhenWithQosReturnsError(t *testing.T) { 98 | provider := new(amqpMock) 99 | provider.withQosFn = func(count, size int, global bool) error { return errAmqp } 100 | r, err := New(dsn, AMQPProvider(provider)) 101 | if r != nil { 102 | t.Fatal("unexpected rabbus value") 103 | } 104 | 105 | if err != errAmqp { 106 | t.Fatalf("expected to have error %v, got %v", errAmqp, err) 107 | } 108 | } 109 | 110 | func testValidateRabbusListener(t *testing.T) { 111 | provider := new(amqpMock) 112 | provider.withQosFn = func(count, size int, global bool) error { return nil } 113 | r, err := New(dsn, AMQPProvider(provider)) 114 | if err != nil { 115 | t.Fatalf("expected to create new rabbus, got %s", err) 116 | } 117 | 118 | defer func(r *Rabbus) { 119 | if err := r.Close(); err != nil { 120 | t.Errorf("expected to close rabbus %s", err) 121 | } 122 | }(r) 123 | 124 | configs := []struct { 125 | config ListenConfig 126 | errMsg string 127 | }{ 128 | { 129 | ListenConfig{}, 130 | "expected to validate exchange", 131 | }, 132 | { 133 | ListenConfig{Exchange: "ex"}, 134 | "expected to validate kind", 135 | }, 136 | { 137 | ListenConfig{Exchange: "ex", Kind: "topic"}, 138 | "expected to validate queue", 139 | }, 140 | } 141 | 142 | for _, c := range configs { 143 | _, err := r.Listen(c.config) 144 | if err == nil { 145 | t.Fatal(c.errMsg) 146 | } 147 | } 148 | } 149 | 150 | func testCreateNewListener(t *testing.T) { 151 | durable := true 152 | config := ListenConfig{ 153 | Exchange: "exchange", 154 | Kind: ExchangeDirect, 155 | Key: "key", 156 | Queue: "queue", 157 | } 158 | provider := new(amqpMock) 159 | provider.withQosFn = func(count, size int, global bool) error { return nil } 160 | provider.createConsumerFn = func(exchange, key, kind, queue string, d bool, declareArgs, bindArgs amqp.Table) (<-chan amqp.Delivery, error) { 161 | if exchange != config.Exchange { 162 | t.Fatalf("unexpected exchange: %s", exchange) 163 | } 164 | if key != config.Key { 165 | t.Fatalf("unexpected key: %s", key) 166 | } 167 | if kind != config.Kind { 168 | t.Fatalf("unexpected kind: %s", kind) 169 | } 170 | if queue != config.Queue { 171 | t.Fatalf("unexpected queue: %s", queue) 172 | } 173 | if d != durable { 174 | t.Fatalf("unexpected durable: %t", d) 175 | } 176 | return make(<-chan amqp.Delivery), nil 177 | } 178 | r, err := New(dsn, AMQPProvider(provider), Durable(durable)) 179 | if err != nil { 180 | t.Fatalf("expected to create new rabbus, got %s", err) 181 | } 182 | 183 | defer func(r *Rabbus) { 184 | if err := r.Close(); err != nil { 185 | t.Errorf("expected to close rabbus %s", err) 186 | } 187 | }(r) 188 | 189 | if _, err = r.Listen(config); err != nil { 190 | t.Fatalf("expected to create listener, got %s", err) 191 | } 192 | 193 | if !provider.createConsumerInvoked { 194 | t.Fatal("expected provider.CreateConsumer() to be invoked") 195 | } 196 | } 197 | 198 | func testFailToCreateNewListenerWhenCreateConsumerReturnsError(t *testing.T) { 199 | provider := new(amqpMock) 200 | provider.withQosFn = func(count, size int, global bool) error { return nil } 201 | provider.createConsumerFn = func(exchange, key, kind, queue string, durable bool, declareArgs, bindArgs amqp.Table) (<-chan amqp.Delivery, error) { 202 | return nil, errAmqp 203 | } 204 | r, err := New(dsn, AMQPProvider(provider)) 205 | if err != nil { 206 | t.Fatalf("expected to create new rabbus, got %s", err.Error()) 207 | } 208 | 209 | _, err = r.Listen(ListenConfig{ 210 | Exchange: "exchange", 211 | Kind: ExchangeDirect, 212 | Key: "key", 213 | Queue: "queue", 214 | }) 215 | if err != errAmqp { 216 | t.Fatalf("expected to have error %v, got %v", errAmqp, err) 217 | } 218 | } 219 | 220 | func testEmitAsyncMessage(t *testing.T) { 221 | msg := Message{ 222 | Exchange: "exchange", 223 | Kind: ExchangeDirect, 224 | Key: "key", 225 | Payload: []byte(`foo`), 226 | } 227 | provider := new(amqpMock) 228 | provider.withQosFn = func(count, size int, global bool) error { return nil } 229 | provider.withExchangeFn = func(exchange, kind string, durable bool) error { 230 | if exchange != msg.Exchange { 231 | t.Fatalf("unexpected exchange: %s", exchange) 232 | } 233 | if kind != msg.Kind { 234 | t.Fatalf("unexpected kind: %s", kind) 235 | } 236 | if durable { 237 | t.Fatalf("unexpected durable: %t", durable) 238 | } 239 | return nil 240 | } 241 | provider.publishFn = func(exchange, key string, opts amqp.Publishing) error { 242 | if exchange != msg.Exchange { 243 | t.Fatalf("unexpected exchange: %s", exchange) 244 | } 245 | if key != msg.Key { 246 | t.Fatalf("unexpected key: %s", key) 247 | } 248 | if string(opts.Body) != string(msg.Payload) { 249 | t.Fatalf("unexpected payload: %s", string(opts.Body)) 250 | } 251 | return nil 252 | } 253 | r, err := New(dsn, AMQPProvider(provider)) 254 | if err != nil { 255 | t.Fatalf("expected to create new rabbus, got %s", err) 256 | } 257 | 258 | defer func(r *Rabbus) { 259 | if err := r.Close(); err != nil { 260 | t.Fatalf("expected to close rabbus %s", err) 261 | } 262 | }(r) 263 | 264 | ctx, cancel := context.WithCancel(context.Background()) 265 | defer cancel() 266 | 267 | go r.Run(ctx) 268 | 269 | r.EmitAsync() <- msg 270 | 271 | outer: 272 | for { 273 | select { 274 | case <-r.EmitOk(): 275 | if !provider.publishInvoked { 276 | t.Fatal("expected provider.Publish() to be invoked") 277 | } 278 | break outer 279 | case err := <-r.EmitErr(): 280 | t.Fatalf("expected to emit message %v: ", err) 281 | break outer 282 | case <-timeout: 283 | t.Fatalf("got timeout error during emit async") 284 | break outer 285 | } 286 | } 287 | } 288 | 289 | func testEmitAsyncMessageFailToDeclareExchange(t *testing.T) { 290 | msg := Message{ 291 | Exchange: "exchange", 292 | Kind: ExchangeDirect, 293 | Key: "key", 294 | Payload: []byte(`foo`), 295 | } 296 | provider := new(amqpMock) 297 | provider.withQosFn = func(count, size int, global bool) error { return nil } 298 | provider.withExchangeFn = func(exchange, kind string, durable bool) error { return errAmqp } 299 | provider.publishFn = func(exchange, key string, opts amqp.Publishing) error { return nil } 300 | r, err := New(dsn, AMQPProvider(provider)) 301 | if err != nil { 302 | t.Fatalf("expected to create new rabbus, got %s", err) 303 | } 304 | 305 | defer func(r *Rabbus) { 306 | if err := r.Close(); err != nil { 307 | t.Fatalf("expected to close rabbus %s", err) 308 | } 309 | }(r) 310 | 311 | ctx, cancel := context.WithCancel(context.Background()) 312 | defer cancel() 313 | 314 | go r.Run(ctx) 315 | 316 | r.EmitAsync() <- msg 317 | outer: 318 | for { 319 | select { 320 | case err := <-r.EmitErr(): 321 | if err != errAmqp { 322 | t.Fatalf("expected to have error %v, got %v", errAmqp, err) 323 | } 324 | if provider.publishInvoked { 325 | t.Fatal("expected provider.Publish() to not be invoked") 326 | } 327 | break outer 328 | case <-timeout: 329 | t.Errorf("got timeout error during emit async") 330 | break outer 331 | } 332 | } 333 | } 334 | 335 | func testEmitAsyncMessageFailToPublish(t *testing.T) { 336 | msg := Message{ 337 | Exchange: "exchange", 338 | Kind: ExchangeDirect, 339 | Key: "key", 340 | Payload: []byte(`foo`), 341 | } 342 | provider := new(amqpMock) 343 | provider.withQosFn = func(count, size int, global bool) error { return nil } 344 | provider.withExchangeFn = func(exchange, kind string, durable bool) error { return nil } 345 | provider.publishFn = func(exchange, key string, opts amqp.Publishing) error { return errAmqp } 346 | r, err := New(dsn, AMQPProvider(provider)) 347 | if err != nil { 348 | t.Fatalf("expected to create new rabbus, got %s", err) 349 | } 350 | 351 | defer func(r *Rabbus) { 352 | if err := r.Close(); err != nil { 353 | t.Errorf("expected to close rabbus %s", err) 354 | } 355 | }(r) 356 | 357 | ctx, cancel := context.WithCancel(context.Background()) 358 | defer cancel() 359 | 360 | go r.Run(ctx) 361 | 362 | r.EmitAsync() <- msg 363 | 364 | outer: 365 | for { 366 | select { 367 | case err := <-r.EmitErr(): 368 | if err != errAmqp { 369 | t.Fatalf("expected to have error %v, got %v", errAmqp, err) 370 | } 371 | break outer 372 | case <-timeout: 373 | t.Errorf("got timeout error during emit async") 374 | break outer 375 | } 376 | } 377 | } 378 | 379 | func testEmitAsyncMessageEnsureBreaker(t *testing.T) { 380 | var breakerCalled bool 381 | fn := func(name, from, to string) { breakerCalled = true } 382 | threshold := uint32(1) 383 | msg := Message{ 384 | Exchange: "exchange", 385 | Kind: ExchangeDirect, 386 | Key: "key", 387 | Payload: []byte(`foo`), 388 | } 389 | provider := new(amqpMock) 390 | provider.withQosFn = func(count, size int, global bool) error { return nil } 391 | provider.withExchangeFn = func(exchange, kind string, durable bool) error { return nil } 392 | provider.publishFn = func(exchange, key string, opts amqp.Publishing) error { return errAmqp } 393 | r, err := New(dsn, AMQPProvider(provider), OnStateChange(fn), Threshold(threshold)) 394 | if err != nil { 395 | t.Fatalf("expected to create new rabbus, got %s", err) 396 | } 397 | 398 | defer func(r *Rabbus) { 399 | if err := r.Close(); err != nil { 400 | t.Errorf("expected to close rabbus %s", err) 401 | } 402 | }(r) 403 | 404 | ctx, cancel := context.WithCancel(context.Background()) 405 | defer cancel() 406 | 407 | go r.Run(ctx) 408 | 409 | r.EmitAsync() <- msg 410 | 411 | count := 0 412 | outer: 413 | for { 414 | select { 415 | case <-r.EmitErr(): 416 | count++ 417 | if count == 2 { 418 | if !breakerCalled { 419 | t.Fatal("expected config.Breaker.OnStateChange to be invoked") 420 | } 421 | break outer 422 | } 423 | r.EmitAsync() <- msg 424 | case <-timeout: 425 | t.Fatal("got timeout error during emit async") 426 | break outer 427 | } 428 | } 429 | } 430 | 431 | type amqpMock struct { 432 | publishInvoked bool 433 | publishFn func(exchange, key string, opts amqp.Publishing) error 434 | createConsumerInvoked bool 435 | createConsumerFn func(exchange, key, kind, queue string, durable bool, declareArgs, bindArgs amqp.Table) (<-chan amqp.Delivery, error) 436 | withExchangeInvoked bool 437 | withExchangeFn func(exchange, kind string, durable bool) error 438 | withQosInvoked bool 439 | withQosFn func(count, size int, global bool) error 440 | } 441 | 442 | func (m *amqpMock) Publish(exchange, key string, opts amqp.Publishing) error { 443 | m.publishInvoked = true 444 | return m.publishFn(exchange, key, opts) 445 | } 446 | 447 | func (m *amqpMock) CreateConsumer(exchange, key, kind, queue string, durable bool, declareArgs, bindArgs amqp.Table) (<-chan amqp.Delivery, error) { 448 | m.createConsumerInvoked = true 449 | return m.createConsumerFn(exchange, key, kind, queue, durable, declareArgs, bindArgs) 450 | } 451 | 452 | func (m *amqpMock) WithExchange(exchange, kind string, durable bool) error { 453 | m.withExchangeInvoked = true 454 | return m.withExchangeFn(exchange, kind, durable) 455 | } 456 | 457 | func (m *amqpMock) WithQos(count, size int, global bool) error { 458 | m.withQosInvoked = true 459 | return m.withQosFn(count, size, global) 460 | } 461 | 462 | func (m *amqpMock) NotifyClose(c chan *amqp.Error) chan *amqp.Error { return nil } 463 | func (m *amqpMock) Close() error { return nil } 464 | --------------------------------------------------------------------------------