@@ -5,148 +5,84 @@ import (
55 "context"
66 "encoding/json"
77 "errors"
8- "fmt"
98 "io"
109 "net/http"
1110 "net/url"
12- "strconv"
1311 "time"
1412
13+ "firebase.google.com/go/messaging"
1514 "github.com/NdoleStudio/httpsms/pkg/telemetry"
1615 "github.com/NdoleStudio/stacktrace"
1716 "github.com/avast/retry-go/v5"
18- "go.opentelemetry.io/otel"
19- "go.opentelemetry.io/otel/attribute"
20- "go.opentelemetry.io/otel/codes"
21- "go.opentelemetry.io/otel/metric"
22- "go.opentelemetry.io/otel/propagation"
23- "google.golang.org/protobuf/types/known/durationpb"
17+ "github.com/google/uuid"
2418)
2519
26- const maxNotificationResponseDiscardBytes = 4 * 1024
27-
28- type httpNotificationRequest struct {
29- Message httpNotificationMessage `json:"message"`
30- }
31-
32- type httpNotificationMessage struct {
33- Token string `json:"token"`
34- Data map [string ]string `json:"data,omitempty"`
35- Android httpNotificationAndroid `json:"android,omitempty"`
36- }
37-
38- type httpNotificationAndroid struct {
39- Priority string `json:"priority,omitempty"`
40- TTL string `json:"ttl,omitempty"`
41- }
20+ const (
21+ maxNotificationResponseDiscardBytes = 4 * 1024
22+ notificationHTTPAttempts = 3
23+ notificationHTTPTimeout = 5 * time .Second
24+ notificationHTTPRetryDelay = 250 * time .Millisecond
25+ )
4226
4327// HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters.
4428type HTTPNotificationSender struct {
45- logger telemetry.Logger
46- tracer telemetry.Tracer
47- client * http.Client
48- attempts uint
49- timeout time.Duration
50- retryDelay time.Duration
51- attemptRecorder notificationHTTPAttemptRecorder
29+ logger telemetry.Logger
30+ client * http.Client
31+ retrier * retry.Retrier
32+ timeout time.Duration
5233}
5334
5435// NewHTTPNotificationSender creates an HTTP notification sender.
5536func NewHTTPNotificationSender (
5637 logger telemetry.Logger ,
57- tracer telemetry.Tracer ,
5838 client * http.Client ,
5939) * HTTPNotificationSender {
60- if client == nil {
61- client = http .DefaultClient
62- }
40+ return newHTTPNotificationSenderWithRetrier (
41+ logger ,
42+ client ,
43+ newHTTPNotificationRetrier (notificationHTTPRetryDelay ),
44+ )
45+ }
6346
47+ func newHTTPNotificationSenderWithRetrier (
48+ logger telemetry.Logger ,
49+ client * http.Client ,
50+ retrier * retry.Retrier ,
51+ ) * HTTPNotificationSender {
6452 return & HTTPNotificationSender {
65- logger : logger ,
66- tracer : tracer ,
67- client : client ,
68- attempts : 3 ,
69- timeout : 5 * time .Second ,
70- retryDelay : 250 * time .Millisecond ,
71- attemptRecorder : newNotificationHTTPAttemptRecorder (tracer ),
53+ logger : logger ,
54+ client : client ,
55+ retrier : retrier ,
56+ timeout : notificationHTTPTimeout ,
7257 }
7358}
7459
7560// Send delivers a notification to an HTTPS adapter. A successful response only accepts wake-up delivery.
7661func (sender * HTTPNotificationSender ) Send (
7762 ctx context.Context ,
78- destination string ,
79- notification GatewayNotification ,
63+ message * messaging. Message ,
64+ notificationID uuid. UUID ,
8065) (string , error ) {
81- endpoint , err := url .Parse (destination )
66+ if message == nil {
67+ return "" , sender .notificationError ("" , "notification message is nil" )
68+ }
69+
70+ endpoint , err := url .Parse (message .Token )
8271 if err != nil {
8372 return "" , sender .notificationError ("" , "cannot parse notification endpoint" )
8473 }
8574 hostname := endpoint .Hostname ()
8675
87- payload := httpNotificationRequest {
88- Message : httpNotificationMessage {
89- Token : destination ,
90- Data : notification .Data ,
91- Android : httpNotificationAndroid {
92- Priority : notification .Priority ,
93- },
94- },
95- }
96- if notification .TTL != nil {
97- payload .Message .Android .TTL = formatProtobufDuration (* notification .TTL )
98- }
99- body , err := json .Marshal (payload )
76+ body , err := encodeHTTPNotificationPayload (message )
10077 if err != nil {
10178 return "" , sender .notificationError (hostname , "cannot encode notification" )
10279 }
10380
104- if sender .attempts == 0 {
105- return "" , sender .notificationError (hostname , "notification sender has no attempts configured" )
106- }
107-
108- attempt := uint (0 )
109- err = retry .New (
110- retry .Attempts (sender .attempts ),
111- retry .Delay (sender .retryDelay ),
112- retry .DelayType (retry .BackOffDelay ),
113- retry .LastErrorOnly (true ),
114- retry .Context (ctx ),
115- retry .RetryIf (isRetryableNotificationError ),
116- ).Do (func () error {
117- attempt ++
118- requestCtx , cancel := context .WithTimeout (ctx , sender .timeout )
119- attemptCtx := requestCtx
120- finishAttempt := func (int , error ) {}
121- if sender .attemptRecorder != nil {
122- attemptCtx , finishAttempt = sender .attemptRecorder .Start (attemptCtx , attempt )
123- }
124-
125- request , requestErr := http .NewRequestWithContext (
126- attemptCtx ,
127- http .MethodPost ,
128- endpoint .String (),
129- bytes .NewReader (body ),
130- )
131- if requestErr != nil {
132- finishAttempt (0 , requestErr )
133- cancel ()
134- return terminalNotificationRequestError {cause : requestErr }
135- }
136- request .Header .Set ("Content-Type" , "application/json" )
137- request .Header .Set ("X-httpSMS-Notification-ID" , notification .NotificationID .String ())
138-
139- statusCode , requestErr := sender .sendAttempt (request )
140- finishAttempt (statusCode , requestErr )
141- cancel ()
142-
143- if ctx .Err () != nil {
144- return terminalNotificationRequestError {cause : ctx .Err ()}
145- }
146- return requestErr
81+ err = sender .retrier .Do (func () error {
82+ return sender .deliver (ctx , endpoint , body , notificationID .String ())
14783 })
14884 if err == nil {
149- return "http/" + notification . NotificationID .String (), nil
85+ return "http/" + notificationID .String (), nil
15086 }
15187 if ctx .Err () != nil {
15288 return "" , sender .notificationError (hostname , "notification request cancelled" )
@@ -155,26 +91,84 @@ func (sender *HTTPNotificationSender) Send(
15591 return "" , sender .notificationError (hostname , "notification request failed" )
15692}
15793
158- func (sender * HTTPNotificationSender ) sendAttempt (request * http.Request ) (int , error ) {
159- otel .GetTextMapPropagator ().Inject (request .Context (), propagation .HeaderCarrier (request .Header ))
94+ func encodeHTTPNotificationPayload (message * messaging.Message ) ([]byte , error ) {
95+ return json .Marshal (map [string ]any {
96+ "message" : message ,
97+ })
98+ }
99+
100+ func (sender * HTTPNotificationSender ) deliver (
101+ ctx context.Context ,
102+ endpoint * url.URL ,
103+ body []byte ,
104+ notificationID string ,
105+ ) error {
106+ if err := ctx .Err (); err != nil {
107+ return terminalNotificationRequestError {cause : err }
108+ }
109+
110+ attemptCtx , cancel := context .WithTimeout (ctx , sender .timeout )
111+ defer cancel ()
112+
113+ request , err := createHTTPNotificationRequest (attemptCtx , endpoint , body , notificationID )
114+ if err != nil {
115+ return terminalNotificationRequestError {cause : err }
116+ }
117+
118+ err = sender .sendAttempt (request )
119+ if ctx .Err () != nil {
120+ return terminalNotificationRequestError {cause : ctx .Err ()}
121+ }
122+ return err
123+ }
124+
125+ func createHTTPNotificationRequest (
126+ ctx context.Context ,
127+ endpoint * url.URL ,
128+ body []byte ,
129+ notificationID string ,
130+ ) (* http.Request , error ) {
131+ request , err := http .NewRequestWithContext (
132+ ctx ,
133+ http .MethodPost ,
134+ endpoint .String (),
135+ bytes .NewReader (body ),
136+ )
137+ if err != nil {
138+ return nil , err
139+ }
140+
141+ request .Header .Set ("Content-Type" , "application/json" )
142+ request .Header .Set ("X-httpSMS-Notification-ID" , notificationID )
143+ return request , nil
144+ }
160145
146+ func (sender * HTTPNotificationSender ) sendAttempt (request * http.Request ) error {
161147 response , err := sender .client .Do (request )
162148 if err != nil {
163- return 0 , err
149+ return err
164150 }
165151 if response .Body != nil {
166152 _ , _ = io .CopyN (io .Discard , response .Body , maxNotificationResponseDiscardBytes )
167153 _ = response .Body .Close ()
168154 }
169155 if response .StatusCode >= http .StatusOK && response .StatusCode < http .StatusMultipleChoices {
170- return response . StatusCode , nil
156+ return nil
171157 }
172158 if isRetryableNotificationStatus (response .StatusCode ) {
173- err = retryableNotificationStatusError {statusCode : response .StatusCode }
174- return response .StatusCode , err
159+ return retryableNotificationStatusError {statusCode : response .StatusCode }
175160 }
176- err = terminalNotificationStatusError {statusCode : response .StatusCode }
177- return response .StatusCode , err
161+ return terminalNotificationStatusError {statusCode : response .StatusCode }
162+ }
163+
164+ func newHTTPNotificationRetrier (delay time.Duration ) * retry.Retrier {
165+ return retry .New (
166+ retry .Attempts (notificationHTTPAttempts ),
167+ retry .Delay (delay ),
168+ retry .DelayType (retry .BackOffDelay ),
169+ retry .LastErrorOnly (true ),
170+ retry .RetryIf (isRetryableNotificationError ),
171+ )
178172}
179173
180174func (sender * HTTPNotificationSender ) notificationError (hostname string , message string ) error {
@@ -238,102 +232,3 @@ func isTerminalNotificationError(err error) bool {
238232 var requestError terminalNotificationRequestError
239233 return errors .As (err , & requestError )
240234}
241-
242- func formatProtobufDuration (value time.Duration ) string {
243- duration := durationpb .New (value )
244- seconds := duration .Seconds
245- nanoseconds := int64 (duration .Nanos )
246- sign := ""
247- if seconds < 0 || nanoseconds < 0 {
248- sign = "-"
249- seconds = - seconds
250- nanoseconds = - nanoseconds
251- }
252-
253- result := sign + strconv .FormatInt (seconds , 10 )
254- if nanoseconds == 0 {
255- return result + "s"
256- }
257-
258- fraction := fmt .Sprintf ("%09d" , nanoseconds )
259- switch {
260- case nanoseconds % 1_000_000 == 0 :
261- fraction = fraction [:3 ]
262- case nanoseconds % 1_000 == 0 :
263- fraction = fraction [:6 ]
264- }
265-
266- return result + "." + fraction + "s"
267- }
268-
269- type notificationHTTPAttemptRecorder interface {
270- Start (context.Context , uint ) (context.Context , func (int , error ))
271- }
272-
273- type otelNotificationHTTPAttemptRecorder struct {
274- tracer telemetry.Tracer
275- attemptCounter metric.Int64Counter
276- durationSeconds metric.Float64Histogram
277- }
278-
279- func newNotificationHTTPAttemptRecorder (tracer telemetry.Tracer ) notificationHTTPAttemptRecorder {
280- if tracer == nil {
281- return nil
282- }
283-
284- meter := otel .GetMeterProvider ().Meter ("github.com/NdoleStudio/httpsms/pkg/services" )
285- attemptCounter , _ := meter .Int64Counter ("httpsms.notification.http.attempts" )
286- durationSeconds , _ := meter .Float64Histogram ("httpsms.notification.http.attempt.duration" )
287-
288- return & otelNotificationHTTPAttemptRecorder {
289- tracer : tracer ,
290- attemptCounter : attemptCounter ,
291- durationSeconds : durationSeconds ,
292- }
293- }
294-
295- func (recorder * otelNotificationHTTPAttemptRecorder ) Start (
296- ctx context.Context ,
297- attempt uint ,
298- ) (context.Context , func (int , error )) {
299- ctx , span := recorder .tracer .Start (ctx , "phone_notification_http" )
300- span .SetAttributes (
301- attribute .String ("notification.transport" , "http" ),
302- attribute .Int ("notification.attempt" , int (attempt )),
303- )
304- startedAt := time .Now ()
305-
306- return ctx , func (statusCode int , err error ) {
307- statusClass := notificationHTTPStatusClass (statusCode , err )
308- attributes := []attribute.KeyValue {
309- attribute .String ("notification.transport" , "http" ),
310- attribute .Int ("notification.attempt" , int (attempt )),
311- attribute .String ("notification.status_class" , statusClass ),
312- }
313- span .SetAttributes (attributes ... )
314- if err != nil {
315- span .SetStatus (codes .Error , "notification HTTP attempt failed" )
316- } else {
317- span .SetStatus (codes .Ok , "" )
318- }
319-
320- options := metric .WithAttributes (attributes ... )
321- if recorder .attemptCounter != nil {
322- recorder .attemptCounter .Add (ctx , 1 , options )
323- }
324- if recorder .durationSeconds != nil {
325- recorder .durationSeconds .Record (ctx , time .Since (startedAt ).Seconds (), options )
326- }
327- span .End ()
328- }
329- }
330-
331- func notificationHTTPStatusClass (statusCode int , err error ) string {
332- if err != nil && statusCode == 0 {
333- return "transport_error"
334- }
335- if statusCode < 100 {
336- return "unknown"
337- }
338- return fmt .Sprintf ("%dxx" , statusCode / 100 )
339- }
0 commit comments