Commit 3beb0f5b68

3beb0f5b683092c145e9a2cf7f2b644358ef3ace

parent: a7d44dc543

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:35 UTC

push: the APNs client

POSTs one alert per call and maps the response: 200 sent, 410 and
BadDeviceToken reap the device, 429 and 5xx retry honouring
Retry-After, everything else dead-letters.

Ref #89
internal/push/apns.go added +126
@@ -0,0 +1,126 @@
1package push
2
3import (
4 "bytes"
5 "context"
6 "crypto/ecdsa"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "strconv"
12 "time"
13
14 "gitbay.org/gitbay/internal/config"
15)
16
17// result is what one send means for the queue row.
18type result int
19
20const (
21 resultSent result = iota // delivered
22 resultRetry // transient; back off and try again
23 resultReap // Apple says the token is dead; drop the device
24 resultDead // permanent for this payload; dead-letter it
25)
26
27// maxBodyBytes keeps an alert inside APNs' 4KB payload limit with room
28// for the rest of the JSON. A summary longer than this is cut rather
29// than rejected.
30const maxBodyBytes = 3000
31
32type Client struct {
33 http *http.Client
34 tokens *tokenSource
35 key *ecdsa.PrivateKey
36 host string
37 scheme string
38 topic string
39}
40
41func NewClient(cfg config.Push) (*Client, error) {
42 c := &Client{
43 // stdlib negotiates HTTP/2 over ALPN, which is what APNs
44 // requires; no explicit http2 transport is needed.
45 http: &http.Client{Timeout: 30 * time.Second},
46 host: cfg.Host(),
47 scheme: "https",
48 topic: cfg.Topic,
49 }
50 if cfg.KeyFile != "" {
51 key, err := config.LoadAPNSKey(cfg.KeyFile)
52 if err != nil {
53 return nil, err
54 }
55 c.key = key
56 c.tokens = newTokenSource(key, cfg.KeyID, cfg.TeamID)
57 }
58 return c, nil
59}
60
61// Send delivers one alert. The returned duration is the server's
62// Retry-After when it gave one, zero otherwise.
63func (c *Client) Send(ctx context.Context, token, title, body, path string) (result, time.Duration, error) {
64 if len(body) > maxBodyBytes {
65 body = body[:maxBodyBytes]
66 }
67 payload, err := json.Marshal(map[string]any{
68 "aps": map[string]any{
69 "alert": map[string]string{"title": title, "body": body},
70 "sound": "default",
71 "thread-id": title,
72 },
73 "path": path,
74 })
75 if err != nil {
76 return resultDead, 0, err
77 }
78 bearer, err := c.tokens.token()
79 if err != nil {
80 return resultRetry, 0, err
81 }
82 url := c.scheme + "://" + c.host + "/3/device/" + token
83 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
84 if err != nil {
85 return resultDead, 0, err
86 }
87 req.Header.Set("authorization", "bearer "+bearer)
88 req.Header.Set("apns-topic", c.topic)
89 req.Header.Set("apns-push-type", "alert")
90 req.Header.Set("apns-priority", "10")
91 req.Header.Set("content-type", "application/json")
92
93 resp, err := c.http.Do(req)
94 if err != nil {
95 return resultRetry, 0, err
96 }
97 defer resp.Body.Close()
98 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
99
100 var apnsErr struct {
101 Reason string `json:"reason"`
102 }
103 json.Unmarshal(raw, &apnsErr)
104
105 var after time.Duration
106 if v := resp.Header.Get("Retry-After"); v != "" {
107 if n, err := strconv.Atoi(v); err == nil && n > 0 {
108 after = time.Duration(n) * time.Second
109 }
110 }
111
112 switch {
113 case resp.StatusCode == http.StatusOK:
114 return resultSent, 0, nil
115 case resp.StatusCode == http.StatusGone,
116 apnsErr.Reason == "BadDeviceToken",
117 apnsErr.Reason == "Unregistered":
118 // Apple is authoritative about which tokens are live.
119 return resultReap, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
120 case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
121 return resultRetry, after, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
122 default:
123 // Retrying a rejected payload will not fix it.
124 return resultDead, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
125 }
126}
internal/push/apns_test.go added +120
@@ -0,0 +1,120 @@
1package push
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 "gitbay.org/gitbay/internal/config"
14)
15
16// fakeAPNs stands in for Apple. It speaks HTTP/1.1; the real transport is
17// h2 by ALPN, which is stdlib behaviour and not this repository's to test.
18func fakeAPNs(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
19 t.Helper()
20 srv := httptest.NewServer(h)
21 t.Cleanup(srv.Close)
22 t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(srv.URL, "http://"))
23 c, err := NewClient(config.Push{
24 Enabled: true, KeyID: "K", TeamID: "T",
25 Topic: "org.gitbay.gitbay", Environment: "production",
26 })
27 if err != nil {
28 t.Fatal(err)
29 }
30 c.key = testKey(t)
31 c.tokens = newTokenSource(c.key, "K", "T")
32 c.scheme = "http"
33 return c, srv
34}
35
36func TestSendShapesTheRequest(t *testing.T) {
37 var gotPath, gotTopic, gotType, gotAuth string
38 var payload map[string]any
39 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
40 gotPath, gotTopic = r.URL.Path, r.Header.Get("apns-topic")
41 gotType, gotAuth = r.Header.Get("apns-push-type"), r.Header.Get("authorization")
42 raw, _ := io.ReadAll(r.Body)
43 json.Unmarshal(raw, &payload)
44 w.WriteHeader(200)
45 })
46 res, _, err := c.Send(context.Background(), "DEVTOKEN", "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12")
47 if err != nil || res != resultSent {
48 t.Fatalf("res = %v, err = %v", res, err)
49 }
50 if gotPath != "/3/device/DEVTOKEN" {
51 t.Fatalf("path = %q", gotPath)
52 }
53 if gotTopic != "org.gitbay.gitbay" || gotType != "alert" {
54 t.Fatalf("topic = %q, push-type = %q", gotTopic, gotType)
55 }
56 if !strings.HasPrefix(gotAuth, "bearer ") {
57 t.Fatalf("authorization = %q", gotAuth)
58 }
59 aps := payload["aps"].(map[string]any)
60 alert := aps["alert"].(map[string]any)
61 if alert["title"] != "krz/gitbay" || alert["body"] != "cmc opened issue #12" {
62 t.Fatalf("alert = %v", alert)
63 }
64 if aps["thread-id"] != "krz/gitbay" {
65 t.Fatalf("thread-id = %v", aps["thread-id"])
66 }
67 if payload["path"] != "krz/gitbay/issues/12" {
68 t.Fatalf("path = %v", payload["path"])
69 }
70 // Collapsing is wrong here: two comments are two notices.
71 if _, ok := payload["apns-collapse-id"]; ok {
72 t.Fatal("collapse id set")
73 }
74}
75
76func TestSendMapsResponses(t *testing.T) {
77 cases := []struct {
78 name string
79 status int
80 body string
81 retryAfter string
82 want result
83 wantAfter time.Duration
84 }{
85 {"ok", 200, "", "", resultSent, 0},
86 {"gone", 410, `{"reason":"Unregistered"}`, "", resultReap, 0},
87 {"bad token", 400, `{"reason":"BadDeviceToken"}`, "", resultReap, 0},
88 {"other 400 is permanent", 400, `{"reason":"PayloadTooLarge"}`, "", resultDead, 0},
89 {"forbidden is permanent", 403, `{"reason":"InvalidProviderToken"}`, "", resultDead, 0},
90 {"too many requests retries", 429, `{"reason":"TooManyRequests"}`, "7", resultRetry, 7 * time.Second},
91 {"server error retries", 503, `{"reason":"ServiceUnavailable"}`, "", resultRetry, 0},
92 }
93 for _, tc := range cases {
94 t.Run(tc.name, func(t *testing.T) {
95 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
96 if tc.retryAfter != "" {
97 w.Header().Set("Retry-After", tc.retryAfter)
98 }
99 w.WriteHeader(tc.status)
100 io.WriteString(w, tc.body)
101 })
102 res, after, err := c.Send(context.Background(), "T", "t", "b", "p")
103 // Only a delivered push has no error. Every other result
104 // carries the status and reason, which is what the drainer
105 // records on the queue row.
106 if tc.want == resultSent && err != nil {
107 t.Fatalf("err = %v", err)
108 }
109 if tc.want != resultSent && err == nil {
110 t.Fatalf("want an error explaining %v, got nil", tc.want)
111 }
112 if res != tc.want {
113 t.Fatalf("res = %v, want %v", res, tc.want)
114 }
115 if after != tc.wantAfter {
116 t.Fatalf("retryAfter = %v, want %v", after, tc.wantAfter)
117 }
118 })
119 }
120}