Skip to content

Commit 5482dce

Browse files
AchoArnoldCopilot
andcommitted
refactor(api): focus notification delivery
Reuse Firebase messages across transports and initialize one reusable retry policy per HTTP sender. Split phone transport dispatch into its own component and rely on the existing HTTP instrumentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d
1 parent cc133f8 commit 5482dce

12 files changed

Lines changed: 601 additions & 649 deletions

api/pkg/di/container.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,6 @@ func (container *Container) PhoneNotificationDispatcher() *services.PhoneNotific
578578
services.NewFCMNotificationSender(container.FCMClient()),
579579
services.NewHTTPNotificationSender(
580580
container.Logger(),
581-
container.Tracer(),
582581
container.NotificationHTTPClient(),
583582
),
584583
)

api/pkg/di/container_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,4 @@ func TestPhoneNotificationDispatcherInjectsNotificationHTTPClient(t *testing.T)
2626
transport := client.FieldByName("Transport").Elem()
2727

2828
assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String())
29-
attemptRecorder := httpSender.FieldByName("attemptRecorder").Elem()
30-
assert.Equal(t, "*services.otelNotificationHTTPAttemptRecorder", attemptRecorder.Type().String())
3129
}

api/pkg/services/http_notification_sender.go

Lines changed: 104 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
4428
type 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.
5536
func 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.
7661
func (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

180174
func (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

Comments
 (0)