|
15 | 15 | package webhook |
16 | 16 |
|
17 | 17 | import ( |
18 | | - "bytes" |
19 | 18 | "context" |
20 | | - "encoding/json" |
21 | 19 | "errors" |
22 | 20 | "fmt" |
23 | 21 | "log/slog" |
24 | 22 | "net/http" |
25 | | - "net/url" |
26 | 23 | "time" |
27 | 24 |
|
| 25 | + "github.com/GoogleChrome/webstatus.dev/lib/event" |
28 | 26 | "github.com/GoogleChrome/webstatus.dev/lib/workertypes" |
29 | 27 | ) |
30 | 28 |
|
@@ -52,97 +50,69 @@ func NewSender(httpClient HTTPClient, stateManager ChannelStateManager, frontend |
52 | 50 | } |
53 | 51 | } |
54 | 52 |
|
55 | | -type SlackPayload struct { |
56 | | - Text string `json:"text"` |
57 | | -} |
| 53 | +var ( |
| 54 | + // ErrTransientWebhook is a transient failure that should be retried. |
| 55 | + ErrTransientWebhook = errors.New("transient webhook failure") |
| 56 | + // ErrPermanentWebhook is a permanent failure that should not be retried. |
| 57 | + ErrPermanentWebhook = errors.New("permanent webhook failure") |
| 58 | +) |
58 | 59 |
|
59 | | -type webhookPreparer interface { |
60 | | - Prepare(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) (*http.Request, error) |
| 60 | +type webhookSender interface { |
| 61 | + Send(ctx context.Context) error |
61 | 62 | } |
62 | 63 |
|
63 | | -type slackPreparer struct { |
64 | | - frontendBaseURL string |
| 64 | +// Manager wraps the type-specific webhook logic. |
| 65 | +type Manager struct { |
| 66 | + sender webhookSender |
65 | 67 | } |
66 | 68 |
|
67 | | -func (s *slackPreparer) Prepare( |
68 | | - ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) (*http.Request, error) { |
69 | | - parsedURL, err := url.Parse(job.WebhookURL) |
70 | | - if err != nil || parsedURL.Scheme != "https" || parsedURL.Host != "hooks.slack.com" { |
71 | | - // Record permanent failure due to invalid URL |
72 | | - return nil, fmt.Errorf("invalid webhook URL: %s", job.WebhookURL) |
73 | | - } |
74 | | - |
75 | | - var summary workertypes.EventSummary |
76 | | - if err := json.Unmarshal(job.SummaryRaw, &summary); err != nil { |
77 | | - return nil, fmt.Errorf("failed to unmarshal summary: %w", err) |
78 | | - } |
79 | | - |
80 | | - resultsURL := fmt.Sprintf("%s/features?q=%s", s.frontendBaseURL, url.QueryEscape(job.Metadata.Query)) |
81 | | - |
82 | | - payload := SlackPayload{ |
83 | | - Text: fmt.Sprintf("WebStatus.dev Notification: %s\nQuery: %s\nView Results: %s", |
84 | | - summary.Text, job.Metadata.Query, resultsURL), |
85 | | - } |
86 | | - |
87 | | - payloadBytes, err := json.Marshal(payload) |
88 | | - if err != nil { |
89 | | - return nil, fmt.Errorf("failed to marshal slack payload: %w", err) |
90 | | - } |
| 69 | +func (s *Sender) getManager(_ context.Context, job workertypes.IncomingWebhookDeliveryJob) (*Manager, error) { |
| 70 | + switch job.WebhookType { |
| 71 | + case workertypes.WebhookTypeSlack: |
| 72 | + slack, err := newSlackSender(s.frontendBaseURL, s.httpClient, job) |
| 73 | + if err != nil { |
| 74 | + return nil, err |
| 75 | + } |
91 | 76 |
|
92 | | - req, err := http.NewRequestWithContext(ctx, http.MethodPost, job.WebhookURL, bytes.NewBuffer(payloadBytes)) |
93 | | - if err != nil { |
94 | | - return nil, fmt.Errorf("failed to create request: %w", err) |
| 77 | + return &Manager{sender: slack}, nil |
| 78 | + default: |
| 79 | + return nil, fmt.Errorf("%w: unsupported type %v", ErrPermanentWebhook, job.WebhookType) |
95 | 80 | } |
96 | | - req.Header.Set("Content-Type", "application/json") |
97 | | - |
98 | | - return req, nil |
99 | 81 | } |
100 | 82 |
|
101 | 83 | func (s *Sender) SendWebhook(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) error { |
102 | 84 | slog.InfoContext(ctx, "sending webhook", "channelID", job.ChannelID, "url", job.WebhookURL) |
103 | 85 |
|
104 | | - var preparer webhookPreparer |
105 | | - switch job.WebhookType { |
106 | | - case workertypes.WebhookTypeSlack: |
107 | | - preparer = &slackPreparer{frontendBaseURL: s.frontendBaseURL} |
108 | | - default: |
109 | | - err := fmt.Errorf("unsupported webhook type: %v", job.WebhookType) |
110 | | - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), true, job.WebhookEventID) |
111 | | - |
112 | | - return err |
113 | | - } |
114 | | - |
115 | | - req, err := preparer.Prepare(ctx, job) |
| 86 | + mgr, err := s.getManager(ctx, job) |
116 | 87 | if err != nil { |
117 | | - // Preparation failures (like invalid payload or URL format) are typically permanent |
118 | | - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), true, job.WebhookEventID) |
| 88 | + // If we fail here, it's permanent when trying to get the manager. |
| 89 | + s.recordFailure(ctx, job, err, true) |
119 | 90 |
|
120 | | - return fmt.Errorf("failed to prepare webhook request: %w", err) |
| 91 | + return fmt.Errorf("failed to prepare webhook: %w", err) |
121 | 92 | } |
122 | 93 |
|
123 | | - resp, err := s.httpClient.Do(req) |
124 | | - if err != nil { |
125 | | - // Transient error? |
126 | | - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), false, job.WebhookEventID) |
| 94 | + if err := mgr.sender.Send(ctx); err != nil { |
| 95 | + isTransient := errors.Is(err, ErrTransientWebhook) |
| 96 | + s.recordFailure(ctx, job, err, !isTransient) |
| 97 | + |
| 98 | + if isTransient { |
| 99 | + return errors.Join(event.ErrTransientFailure, err) |
| 100 | + } |
127 | 101 |
|
128 | 102 | return fmt.Errorf("failed to send webhook: %w", err) |
129 | 103 | } |
130 | | - defer resp.Body.Close() |
131 | | - |
132 | | - if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
133 | | - // Success |
134 | | - _ = s.stateManager.RecordSuccess(ctx, job.ChannelID, time.Now(), job.WebhookEventID) |
135 | 104 |
|
136 | | - return nil |
| 105 | + if err := s.stateManager.RecordSuccess(ctx, job.ChannelID, time.Now(), job.WebhookEventID); err != nil { |
| 106 | + slog.WarnContext(ctx, "failed to record success", "error", err) |
137 | 107 | } |
138 | 108 |
|
139 | | - // Failure |
140 | | - errorMsg := fmt.Sprintf("webhook returned status code %d", resp.StatusCode) |
141 | | - webhookErr := errors.New(errorMsg) |
142 | | - isPermanent := resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone || |
143 | | - resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden |
144 | | - |
145 | | - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, webhookErr, time.Now(), isPermanent, job.WebhookEventID) |
| 109 | + return nil |
| 110 | +} |
146 | 111 |
|
147 | | - return fmt.Errorf("webhook failed: %s", errorMsg) |
| 112 | +func (s *Sender) recordFailure(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob, |
| 113 | + err error, permanent bool) { |
| 114 | + if dbErr := s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), |
| 115 | + permanent, job.WebhookEventID); dbErr != nil { |
| 116 | + slog.ErrorContext(ctx, "failed to record failure", "error", dbErr) |
| 117 | + } |
148 | 118 | } |
0 commit comments