All checks were successful
check / check (push) Successful in 3s
Updates golangci-lint to v2.12.2 and sets `.golangci.yml` to the org-standard v2-schema config already deployed across the org's repos. The config change is owner-authorized (see #96 (comment) and #96 (comment)); the same file is being landed as canonical via prompts PR #24 (sneak/prompts#24). ## Changes - **Commit-pinned installs**: golangci-lint pinned to commit `c0d3ddc9cf3faa61a4e378e879ece580256d76e5` (v2.12.2, released 2026-05-06) in `Dockerfile` and `script/bootstrap`. - **`.golangci.yml` set to the org-standard v2 config** (sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`), byte-identical to the file used across the org's other repos. Settings live under `linters.settings`, so the `lll`/`funlen`/`cyclop`/`dupl` thresholds are actually applied (under the old hybrid file, v2 silently ignored the top-level `linters-settings` block). - **Lint fixes** required by the now-active thresholds: - `goconst`: shared constants for repeated status/priority/DNS-fixture strings in `internal/watcher/watcher.go` and the notify, state, and watcher tests - `dupl`: consolidated duplicated ntfy/slack HTTP-error tests and SendNotification endpoint-error tests behind shared helpers in `internal/notify/delivery_test.go` - `lll`: wrapped long test table entries and comments in `internal/config/classify_test.go`, `internal/notify/history_test.go`, `internal/state/state_test.go`, `internal/watcher/watcher_test.go`; shortened one inline nolint justification in `internal/notify/retry.go` - **`TODO.md`**: Completed Steps entry updated in the same commit. - Rebased onto current `main` (`f79cd98`); the branch is one clean commit. ## Notes - v2.12 deprecates the `gomodguard` linter in favor of `gomodguard_v2`. The org-standard config does not disable the deprecated linter, so golangci-lint may emit an informational deprecation warning; this is accepted by the owner and does not affect the exit status (this exact config+code combination was CI-green at `dea7e44`). ## Verification - `make check` exits 0 (fmt-check, tests, lint) - `make lint`: 0 issues; no deprecation warning surfaced in the runs performed - sha256 of `.golangci.yml` at HEAD verified equal to `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb` Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #96 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
140 lines
3.0 KiB
Go
140 lines
3.0 KiB
Go
package notify
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"math/rand/v2"
|
|
"time"
|
|
)
|
|
|
|
// Retry defaults.
|
|
const (
|
|
// DefaultMaxRetries is the number of additional attempts
|
|
// after the first failure.
|
|
DefaultMaxRetries = 5
|
|
|
|
// DefaultBaseDelay is the initial delay before the first
|
|
// retry attempt.
|
|
DefaultBaseDelay = 1 * time.Second
|
|
|
|
// DefaultMaxDelay caps the computed backoff delay.
|
|
DefaultMaxDelay = 60 * time.Second
|
|
|
|
// backoffMultiplier is the exponential growth factor.
|
|
backoffMultiplier = 2
|
|
|
|
// jitterFraction controls the ±random spread applied
|
|
// to each delay (0.25 = ±25%).
|
|
jitterFraction = 0.25
|
|
)
|
|
|
|
// RetryConfig holds tuning knobs for the retry loop.
|
|
// Zero values fall back to the package defaults above.
|
|
type RetryConfig struct {
|
|
MaxRetries int
|
|
BaseDelay time.Duration
|
|
MaxDelay time.Duration
|
|
}
|
|
|
|
// defaults returns a copy with zero fields replaced by
|
|
// package defaults.
|
|
func (rc RetryConfig) defaults() RetryConfig {
|
|
if rc.MaxRetries <= 0 {
|
|
rc.MaxRetries = DefaultMaxRetries
|
|
}
|
|
|
|
if rc.BaseDelay <= 0 {
|
|
rc.BaseDelay = DefaultBaseDelay
|
|
}
|
|
|
|
if rc.MaxDelay <= 0 {
|
|
rc.MaxDelay = DefaultMaxDelay
|
|
}
|
|
|
|
return rc
|
|
}
|
|
|
|
// backoff computes the delay for attempt n (0-indexed) with
|
|
// jitter. The raw delay is BaseDelay * 2^n, capped at
|
|
// MaxDelay, then randomised by ±jitterFraction.
|
|
func (rc RetryConfig) backoff(attempt int) time.Duration {
|
|
raw := float64(rc.BaseDelay) *
|
|
math.Pow(backoffMultiplier, float64(attempt))
|
|
|
|
if raw > float64(rc.MaxDelay) {
|
|
raw = float64(rc.MaxDelay)
|
|
}
|
|
|
|
// Apply jitter: uniform in [raw*(1-j), raw*(1+j)].
|
|
lo := raw * (1 - jitterFraction)
|
|
hi := raw * (1 + jitterFraction)
|
|
|
|
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand
|
|
|
|
return time.Duration(jittered)
|
|
}
|
|
|
|
// deliverWithRetry calls fn, retrying on error with
|
|
// exponential backoff. It logs every failed attempt and
|
|
// returns the last error if all attempts are exhausted.
|
|
func (svc *Service) deliverWithRetry(
|
|
ctx context.Context,
|
|
endpoint string,
|
|
fn func(context.Context) error,
|
|
) error {
|
|
cfg := svc.retryConfig.defaults()
|
|
|
|
var lastErr error
|
|
|
|
// attempt 0 is the initial call; attempts 1..MaxRetries
|
|
// are retries.
|
|
for attempt := range cfg.MaxRetries + 1 {
|
|
lastErr = fn(ctx)
|
|
if lastErr == nil {
|
|
if attempt > 0 {
|
|
svc.log.Info(
|
|
"notification delivered after retry",
|
|
"endpoint", endpoint,
|
|
"attempt", attempt+1,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Last attempt — don't sleep, just return.
|
|
if attempt == cfg.MaxRetries {
|
|
break
|
|
}
|
|
|
|
delay := cfg.backoff(attempt)
|
|
|
|
svc.log.Warn(
|
|
"notification delivery failed, retrying",
|
|
"endpoint", endpoint,
|
|
"attempt", attempt+1,
|
|
"maxAttempts", cfg.MaxRetries+1,
|
|
"retryIn", delay,
|
|
"error", lastErr,
|
|
)
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-svc.sleepFunc(delay):
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// sleepFunc returns a channel that closes after d.
|
|
// It is a field-level indirection so tests can override it.
|
|
func (svc *Service) sleepFunc(d time.Duration) <-chan time.Time {
|
|
if svc.sleepFn != nil {
|
|
return svc.sleepFn(d)
|
|
}
|
|
|
|
return time.After(d)
|
|
}
|