fix(migration): add retry to migration request helper

Resolves https://github.com/go-vikunja/vikunja/issues/1788
This commit is contained in:
kolaente
2025-11-12 20:10:32 +01:00
parent d8446e4421
commit 9efc0baf50
2 changed files with 144 additions and 11 deletions

View File

@@ -19,9 +19,13 @@ package migration
import (
"bytes"
"context"
"crypto/rand"
"math"
"math/big"
"net/http"
"net/url"
"strings"
"time"
)
// DownloadFile downloads a file and returns its contents
@@ -61,17 +65,48 @@ func DoPost(url string, form url.Values) (resp *http.Response, err error) {
// DoPostWithHeaders does an api request and allows to pass in arbitrary headers
func DoPostWithHeaders(url string, form url.Values, headers map[string]string) (resp *http.Response, err error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, strings.NewReader(form.Encode()))
if err != nil {
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
for key, value := range headers {
req.Header.Add(key, value)
}
const maxRetries = 3
const baseDelay = 100 * time.Millisecond
hc := http.Client{}
return hc.Do(req)
for attempt := 0; attempt < maxRetries; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
for key, value := range headers {
req.Header.Add(key, value)
}
resp, err = hc.Do(req)
if err != nil {
return nil, err
}
// Don't retry on non-5xx status codes
if resp.StatusCode < 500 {
return resp, nil
}
// Don't retry on last attempt
if attempt == maxRetries-1 {
return resp, nil
}
// Close the body before retrying
resp.Body.Close()
// Exponential backoff with jitter
delay := baseDelay * time.Duration(math.Pow(2, float64(attempt)))
maxJitter := int64(delay / 2)
jitterBig, _ := rand.Int(rand.Reader, big.NewInt(maxJitter))
jitter := time.Duration(jitterBig.Int64())
time.Sleep(delay + jitter)
}
return resp, nil
}

View File

@@ -0,0 +1,98 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
)
func TestDoPostWithHeaders_RetriesOn500(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
count := attempts.Add(1)
if count < 3 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
form := url.Values{"key": {"value"}}
resp, err := DoPostWithHeaders(server.URL, form, map[string]string{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
if attempts.Load() != 3 {
t.Errorf("expected 3 attempts, got %d", attempts.Load())
}
}
func TestDoPostWithHeaders_GivesUpAfter3Retries(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
form := url.Values{"key": {"value"}}
resp, err := DoPostWithHeaders(server.URL, form, map[string]string{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", resp.StatusCode)
}
if attempts.Load() != 3 {
t.Errorf("expected 3 attempts, got %d", attempts.Load())
}
}
func TestDoPostWithHeaders_DoesNotRetryOn4xx(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
form := url.Values{"key": {"value"}}
resp, err := DoPostWithHeaders(server.URL, form, map[string]string{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", resp.StatusCode)
}
if attempts.Load() != 1 {
t.Errorf("expected 1 attempt (no retries on 4xx), got %d", attempts.Load())
}
}