diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go index f0d7f5e..70e18f9 100644 --- a/internal/extsvc/manager.go +++ b/internal/extsvc/manager.go @@ -87,17 +87,76 @@ type Manager struct { mu sync.Mutex cfg ExternalServices rnd *rand.Rand + + // uploadCh serialises immediate auto-uploads through a single worker. Firing a + // goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens + // of concurrent requests at once — Club Log's nginx answers that with 403, the + // upload is counted as failed and the QSO stays at "R" despite the others going + // through. One-at-a-time with a small gap keeps every upload under the limit. + uploadCh chan uploadJob } +// uploadJob is one queued auto-upload. +type uploadJob struct { + svc Service + id int64 + cfg ServiceConfig + attempt int // 0 on first try; incremented on each retry +} + +// uploadGap spaces serialized uploads so a burst never trips a service's per-IP +// rate limiter. maxUploadAttempts bounds retries of a transient failure. +const ( + uploadGap = 250 * time.Millisecond + maxUploadAttempts = 4 +) + func NewManager(deps Deps) *Manager { if deps.Client == nil { deps.Client = &http.Client{Timeout: 20 * time.Second} } - return &Manager{ + m := &Manager{ deps: deps, // Seeded from the clock; the delay only needs to be unpredictable // enough to spread bursts, not cryptographically random. - rnd: rand.New(rand.NewSource(time.Now().UnixNano())), + rnd: rand.New(rand.NewSource(time.Now().UnixNano())), + uploadCh: make(chan uploadJob, 4096), + } + go m.uploadWorker() + return m +} + +// uploadWorker drains the queue one upload at a time, spacing them so a burst of +// freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient +// failure is re-queued with an exponential back-off, so a QSO that hit a +// momentary rate-limit still ends up marked instead of stuck at "R". +func (m *Manager) uploadWorker() { + for job := range m.uploadCh { + ok, retryable := m.upload(job.svc, job.id, job.cfg) + if !ok && retryable && job.attempt+1 < maxUploadAttempts { + next := job + next.attempt++ + backoff := time.Duration(1<