Files
gdm257andGitHub 9fc59f0c48 Support WebDAV destination (#359)
* feat(destination): add WebDAV destination

Add a stdlib-only WebDAV destination mirroring the s3/azureblob flow:
bare-clone into a temp dir, optional zip, upload via HTTP PUT with Basic
auth, and prune stale remote files via PROPFIND/DELETE.

- types: WebDAVRepo struct, Destination.WebDAV field, Count()
- webdav: new package (UploadDirToWebDAV, DeleteObjectsNotInRepo)
- main: dispatch loop on par with azureblob
- conf.example.yml + gickup_spec.json: document the new destination

* feat: auto retry


Auto retry network errors and some situations.

Servers that disable Depth: infinity (Sabre/DAV, Nextcloud and others
answer 400/403/501) failed listing and could not prune stale files.
Try infinity once, fall back to recursive PROPFIND walk on rejection.

* refactor: use gowebdav client

Classify retry errors and make backoff interruptible.
2026-08-18 19:54:39 +02:00

89 lines
2.8 KiB
Go

package webdav
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"syscall"
"testing"
"time"
)
// fakeNetError is a minimal net.Error for table tests.
type fakeNetError struct{ timeout bool }
func (fakeNetError) Error() string { return "net error" }
func (e fakeNetError) Timeout() bool { return e.timeout }
func (fakeNetError) Temporary() bool { return false }
func TestShouldRetry(t *testing.T) {
t.Parallel()
cases := []struct {
name string
resp *http.Response
err error
want bool
}{
{"503", &http.Response{StatusCode: http.StatusServiceUnavailable}, nil, true},
{"429", &http.Response{StatusCode: http.StatusTooManyRequests}, nil, true},
{"500", &http.Response{StatusCode: http.StatusInternalServerError}, nil, true},
{"502", &http.Response{StatusCode: http.StatusBadGateway}, nil, true},
{"504", &http.Response{StatusCode: http.StatusGatewayTimeout}, nil, true},
{"200", &http.Response{StatusCode: http.StatusOK}, nil, false},
{"404", &http.Response{StatusCode: http.StatusNotFound}, nil, false},
{"401", &http.Response{StatusCode: http.StatusUnauthorized}, nil, false},
{"net timeout", nil, fakeNetError{timeout: true}, true},
{"eof", nil, io.EOF, true},
{"unexpected eof", nil, io.ErrUnexpectedEOF, true},
{"conn reset", nil, syscall.ECONNRESET, true},
{"conn refused", nil, syscall.ECONNREFUSED, true},
{"broken pipe", nil, syscall.EPIPE, true},
{"wrapped eof", nil, fmt.Errorf("wrap: %w", io.EOF), true},
{"context canceled", nil, context.Canceled, false},
{"context deadline", nil, context.DeadlineExceeded, false},
{"plain error", nil, errors.New("boom"), false},
{"non-timeout net", nil, fakeNetError{timeout: false}, false},
}
for _, c := range cases {
if got := shouldRetry(c.resp, c.err); got != c.want {
t.Errorf("%s: got %v want %v", c.name, got, c.want)
}
}
}
// TestRetryAbortsOnContextCancel verifies the backoff sleep yields to a
// canceled request context instead of blocking for the full retry window.
func TestRetryAbortsOnContextCancel(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(60*time.Millisecond, cancel)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
if err != nil {
t.Fatal(err)
}
start := time.Now()
resp, err := (&retryRoundTripper{base: http.DefaultTransport}).RoundTrip(req)
elapsed := time.Since(start)
if resp != nil {
resp.Body.Close()
}
if err == nil {
t.Fatal("expected error from canceled context")
}
if elapsed > 400*time.Millisecond {
t.Fatalf("retry did not abort on cancel: %v (backoff base is 500ms)", elapsed)
}
}