Files
larksuite__cli/internal/download/download.go

717 lines
21 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package download provides validated full and ranged streams.
package download
import (
"context"
"errors"
"fmt"
"io"
"math"
"math/rand/v2"
"net/http"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
)
const (
// DefaultPartSize keeps a 100 MiB object near 13 requests while capping replay at 8 MiB.
DefaultPartSize = int64(8 * 1024 * 1024)
// DefaultPartRetries tolerates three transient failures without prolonged retrying.
DefaultPartRetries = 3
// DefaultRetryDelay keeps three local backoffs below one second before jitter.
DefaultRetryDelay = 100 * time.Millisecond
// DefaultRetryWaitBudget caps cumulative local sleep for interactive callers.
DefaultRetryWaitBudget = 3 * time.Second
// DefaultIdleTimeout detects a dead connection within a minute without limiting slow progress.
DefaultIdleTimeout = 60 * time.Second
)
// ByteRange is an inclusive HTTP byte range.
type ByteRange struct {
Start int64
End int64
}
// HeaderValue returns the value for an HTTP Range header.
func (r ByteRange) HeaderValue() string {
return fmt.Sprintf("bytes=%d-%d", r.Start, r.End)
}
// Request describes one full or ranged fetch.
type Request struct {
Range *ByteRange
IfRange string
}
// Headers keeps byte offsets tied to the transferred representation.
func (r Request) Headers() http.Header {
headers := make(http.Header, 3)
headers.Set("Accept-Encoding", "identity")
if r.Range != nil {
headers.Set("Range", r.Range.HeaderValue())
}
if r.IfRange != "" {
headers.Set("If-Range", r.IfRange)
}
return headers
}
// Transport performs one replay-safe fetch and binds ctx to the response body.
// Non-successful HTTP responses must be returned as typed errors with
// retryability decided at this boundary.
type Transport func(context.Context, Request) (*http.Response, error)
// Options controls multipart behavior. Zero values select production defaults.
type Options struct {
PartSize int64
MaxResponses int
// MaxPartRetries bounds retries per range. Zero selects the default.
MaxPartRetries int
// RetryDelay is the base exponential backoff. Zero selects the default.
RetryDelay time.Duration
// RetryWaitBudget bounds cumulative retry sleeps. Zero selects the default.
RetryWaitBudget time.Duration
// IdleTimeout bounds waiting for response headers or one body read. Time
// between caller reads does not count. Zero selects the default.
IdleTimeout time.Duration
// DisableMultipart forces one full response.
DisableMultipart bool
}
// Stream is one logical full or multipart response.
type Stream struct {
Body io.ReadCloser
Header http.Header
ContentLength int64
}
// Open probes range support and returns one validated stream.
func Open(ctx context.Context, source Source, opts Options) (*Stream, error) {
opts = opts.withDefaults()
if err := validateOptions(source, opts); err != nil {
return nil, err
}
retryWait := newRetryWaitBudget(opts.RetryWaitBudget)
if opts.DisableMultipart {
return openFull(ctx, source.transport, opts, retryWait)
}
firstPart := ByteRange{Start: 0, End: opts.PartSize - 1}
resp, err := fetchWithRetry(ctx, source.transport, Request{Range: &firstPart}, opts, retryWait)
if err != nil {
if !rangeProbeRejected(err) {
return nil, err
}
return openFull(ctx, source.transport, opts, retryWait)
}
if resp == nil || resp.Body == nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkProtocol, "range fetch returned an empty response")
}
switch resp.StatusCode {
case http.StatusOK:
return singleStream(ctx, resp)
case http.StatusPartialContent:
return openPartial(ctx, source, opts, retryWait, firstPart, resp)
case http.StatusBadRequest, http.StatusRequestedRangeNotSatisfiable:
resp.Body.Close()
return openFull(ctx, source.transport, opts, retryWait)
default:
resp.Body.Close()
return nil, unexpectedStatus(resp.StatusCode)
}
}
func openFull(ctx context.Context, fetch Transport, opts Options, retryWait *retryWaitBudget) (*Stream, error) {
resp, err := fetchWithRetry(ctx, fetch, Request{}, opts, retryWait)
if err != nil {
return nil, err
}
if resp == nil || resp.Body == nil {
return nil, protocolError("full-response fallback returned an empty response")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, protocolError("full-response fallback returned HTTP %d, want 200", resp.StatusCode)
}
return singleStream(ctx, resp)
}
func rangeProbeRejected(err error) bool {
problem, ok := errs.ProblemOf(err)
return ok && (problem.Code == http.StatusBadRequest || problem.Code == http.StatusRequestedRangeNotSatisfiable)
}
func fetchWithRetry(ctx context.Context, fetch Transport, request Request, opts Options, retryWait *retryWaitBudget) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= opts.MaxPartRetries; attempt++ {
resp, err := fetchWithIdleTimeout(ctx, fetch, request, opts.IdleTimeout)
if err == nil {
return resp, nil
}
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
lastErr = err
if ctx.Err() != nil {
return nil, downloadContextError(ctx.Err())
}
if attempt == opts.MaxPartRetries || !downloadRetryable(ctx, err) {
return nil, err
}
if waitErr := waitForRetry(ctx, retryWait, opts.RetryDelay, attempt, err); waitErr != nil {
return nil, waitErr
}
}
return nil, lastErr
}
func downloadContextError(err error) error {
if errors.Is(err, context.DeadlineExceeded) {
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "download timed out").WithCause(err)
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download canceled").WithCause(err)
}
func downloadRetryable(ctx context.Context, err error) bool {
return err != nil && ctx.Err() == nil && errs.IsRetryable(err)
}
func waitForRetry(ctx context.Context, retryWait *retryWaitBudget, base time.Duration, attempt int, retryErr error) error {
delay := addRetryJitter(retryDelay(base, attempt, retryErr))
return retryWait.wait(ctx, delay, retryErr)
}
type retryWaitBudget struct {
remaining time.Duration
}
func newRetryWaitBudget(limit time.Duration) *retryWaitBudget {
return &retryWaitBudget{remaining: limit}
}
func (b *retryWaitBudget) wait(ctx context.Context, delay time.Duration, retryErr error) error {
if delay <= 0 {
return nil
}
if b == nil || delay > b.remaining {
return retryErr
}
b.remaining -= delay
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return downloadContextError(ctx.Err())
case <-timer.C:
return nil
}
}
func retryDelay(base time.Duration, attempt int, retryErr error) time.Duration {
local := exponentialDelay(base, attempt)
if upstream, ok := errs.RetryAfter(retryErr); ok && upstream > local {
return upstream
}
return local
}
func exponentialDelay(base time.Duration, attempt int) time.Duration {
if base <= 0 {
return 0
}
shift := min(max(attempt, 0), 4)
factor := int64(1 << uint(shift))
if base > time.Duration(math.MaxInt64/factor) {
return time.Duration(math.MaxInt64)
}
return time.Duration(int64(base) * factor)
}
// addRetryJitter never shortens an upstream retry delay.
func addRetryJitter(delay time.Duration) time.Duration {
if delay <= 0 {
return 0
}
window := min(delay/10, 250*time.Millisecond)
if window <= 0 {
return delay
}
extra := time.Duration(rand.Int64N(int64(window) + 1))
if delay > time.Duration(math.MaxInt64)-extra {
return time.Duration(math.MaxInt64)
}
return delay + extra
}
func (o Options) withDefaults() Options {
if o.PartSize == 0 {
o.PartSize = DefaultPartSize
}
if o.MaxPartRetries == 0 {
o.MaxPartRetries = DefaultPartRetries
}
if o.RetryDelay == 0 {
o.RetryDelay = DefaultRetryDelay
}
if o.RetryWaitBudget == 0 {
o.RetryWaitBudget = DefaultRetryWaitBudget
}
if o.IdleTimeout == 0 {
o.IdleTimeout = DefaultIdleTimeout
}
return o
}
func validateOptions(source Source, opts Options) error {
if source.transport == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "download requires a configured transport")
}
if source.representation != immutableRepresentation && source.representation != mutableRepresentation {
return errs.NewInternalError(errs.SubtypeUnknown, "download requires an explicit representation contract")
}
if opts.PartSize <= 0 {
return errs.NewInternalError(errs.SubtypeUnknown, "download part size must be positive, got %d", opts.PartSize)
}
if opts.MaxResponses < 0 {
return errs.NewInternalError(errs.SubtypeUnknown, "download max responses cannot be negative, got %d", opts.MaxResponses)
}
if opts.MaxPartRetries < 0 {
return errs.NewInternalError(errs.SubtypeUnknown, "download max part retries cannot be negative, got %d", opts.MaxPartRetries)
}
if opts.RetryDelay < 0 {
return errs.NewInternalError(errs.SubtypeUnknown, "download retry delay cannot be negative, got %s", opts.RetryDelay)
}
if opts.RetryWaitBudget < 0 {
return errs.NewInternalError(errs.SubtypeUnknown, "download retry wait budget cannot be negative, got %s", opts.RetryWaitBudget)
}
if opts.IdleTimeout < 0 {
return errs.NewInternalError(errs.SubtypeUnknown, "download idle timeout cannot be negative, got %s", opts.IdleTimeout)
}
return nil
}
func singleStream(ctx context.Context, resp *http.Response) (*Stream, error) {
if err := validateResponseEncoding(resp); err != nil {
resp.Body.Close()
return nil, err
}
contentLength := resp.ContentLength
if contentLength < 0 || (contentLength == 0 && resp.Header.Get("Content-Length") == "") {
contentLength = -1
}
return &Stream{
Body: newExactLengthReader(newClassifiedBody(ctx, resp.Body), contentLength),
Header: resp.Header.Clone(),
ContentLength: contentLength,
}, nil
}
func openPartial(ctx context.Context, source Source, opts Options, retryWait *retryWaitBudget, requested ByteRange, resp *http.Response) (*Stream, error) {
if err := validateResponseEncoding(resp); err != nil {
resp.Body.Close()
return nil, err
}
first, err := parseContentRange(resp.Header.Get("Content-Range"))
if err != nil {
resp.Body.Close()
return nil, protocolError("invalid Content-Range header on range response: %s", err)
}
if first.start != 0 {
resp.Body.Close()
return nil, protocolError("range response is %s, want it to start at byte 0", first)
}
if first.end > requested.End {
resp.Body.Close()
return nil, protocolError("range response is %s, outside requested %s", first, requested.HeaderValue())
}
session := newRepresentationSession(source, first, resp.Header)
completeInFirstResponse := first.end == first.total-1
if !completeInFirstResponse && !session.multipartAllowed() {
resp.Body.Close()
return openFull(ctx, source.transport, opts, retryWait)
}
limit := opts.MaxResponses
if limit == 0 {
limit = responseLimit(first.total, opts.PartSize)
}
body := &sequentialPartReader{
ctx: ctx,
session: session,
opts: opts,
current: resp.Body,
chunkSize: opts.PartSize,
chunkWant: first.length(),
responses: 1,
maxResponses: limit,
maxPartRetries: opts.MaxPartRetries,
retryDelay: opts.RetryDelay,
retryWait: retryWait,
}
return &Stream{
Body: newExactLengthReader(body, first.total),
Header: resp.Header.Clone(),
ContentLength: first.total,
}, nil
}
type sequentialPartReader struct {
ctx context.Context
session *representationSession
opts Options
current io.ReadCloser
chunkSize int64
chunkWant int64
chunkRead int64
responses int
maxResponses int
nextOffset int64
maxPartRetries int
partRetries int
retryDelay time.Duration
retryWait *retryWaitBudget
retryErr error
retryPending bool
closed bool
}
func (r *sequentialPartReader) Read(p []byte) (int, error) {
if r.closed {
return 0, io.ErrClosedPipe
}
for {
if r.current != nil {
n, err := r.current.Read(p)
r.chunkRead += int64(n)
r.nextOffset += int64(n)
if r.chunkRead > r.chunkWant {
return 0, r.closeAfter(protocolError("range response delivered more than the %d bytes its framing declared", r.chunkWant))
}
switch err {
case nil:
return n, nil
case io.EOF:
closeErr := r.current.Close()
r.current = nil
if r.chunkRead != r.chunkWant {
failure := r.readFailure(io.ErrUnexpectedEOF, closeErr)
if r.scheduleBodyRetry(failure) {
if n > 0 {
return n, nil
}
continue
}
return n, failure
}
// Closing cannot invalidate a complete framed response.
r.partRetries = 0
if r.nextOffset == r.session.totalSize {
if n > 0 {
return n, nil
}
return 0, io.EOF
}
r.chunkRead = 0
r.chunkWant = 0
if n > 0 {
return n, nil
}
default:
closeErr := r.current.Close()
r.current = nil
failure := r.readFailure(err, closeErr)
if r.scheduleBodyRetry(failure) {
if n > 0 {
return n, nil
}
continue
}
return n, failure
}
}
if r.nextOffset >= r.session.totalSize {
if r.nextOffset == r.session.totalSize {
return 0, io.EOF
}
return 0, protocolError("file size mismatch: expected %d, got %d", r.session.totalSize, r.nextOffset)
}
if r.responses >= r.maxResponses {
return 0, protocolError(
"server split the resource into more than %d range responses; giving up after %d of %d bytes",
r.maxResponses, r.nextOffset, r.session.totalSize,
)
}
if r.retryPending {
if err := r.waitBeforeRetry(); err != nil {
return 0, err
}
r.retryPending = false
r.retryErr = nil
}
if err := r.openNext(); err != nil {
return 0, err
}
}
}
func (r *sequentialPartReader) scheduleBodyRetry(failure error) bool {
if !r.canRetryBody(failure) || r.partRetries >= r.maxPartRetries {
return false
}
r.retryErr = failure
r.partRetries++
r.retryPending = true
r.chunkRead = 0
r.chunkWant = 0
return true
}
func (r *sequentialPartReader) openNext() error {
end := r.session.totalSize - 1
if remaining := r.session.totalSize - r.nextOffset; r.chunkSize < remaining {
end = r.nextOffset + r.chunkSize - 1
}
requested := ByteRange{Start: r.nextOffset, End: end}
resp, err := fetchWithRetry(r.ctx, r.session.transport, r.session.request(requested), r.opts, r.retryWait)
if err != nil {
return err
}
if resp == nil || resp.Body == nil {
return protocolError("range fetch returned an empty response")
}
r.responses++
if resp.StatusCode == http.StatusOK {
return r.continueFullResponse(resp)
}
if resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
return unexpectedStatus(resp.StatusCode)
}
if err := validateResponseEncoding(resp); err != nil {
resp.Body.Close()
return err
}
got, parseErr := parseContentRange(resp.Header.Get("Content-Range"))
if parseErr != nil {
resp.Body.Close()
return protocolError("invalid Content-Range header on range response: %s", parseErr)
}
if validateErr := r.session.validatePartial(resp.Header, got, requested); validateErr != nil {
resp.Body.Close()
return validateErr
}
if got.end < r.nextOffset {
resp.Body.Close()
return protocolError("range response is %s and makes no progress past byte %d", got, r.nextOffset)
}
r.current = resp.Body
r.chunkWant = got.length()
r.chunkRead = 0
return nil
}
// continueFullResponse uses a peer's full response after it stops honoring Range.
func (r *sequentialPartReader) continueFullResponse(resp *http.Response) error {
if err := validateResponseEncoding(resp); err != nil {
resp.Body.Close()
return err
}
if validateErr := r.session.validateFull(resp); validateErr != nil {
resp.Body.Close()
return validateErr
}
body := newClassifiedBody(r.ctx, resp.Body)
if _, err := io.CopyN(io.Discard, body, r.nextOffset); err != nil {
body.Close()
if _, typed := errs.ProblemOf(err); typed {
return err
}
return protocolError("full-response continuation ended before byte %d: %s", r.nextOffset, err).WithCause(err)
}
r.current = body
r.chunkWant = r.session.totalSize - r.nextOffset
r.chunkRead = 0
return nil
}
func (r *sequentialPartReader) canRetryBody(err error) bool {
return r.session.multipartAllowed() && downloadRetryable(r.ctx, err)
}
func (r *sequentialPartReader) readFailure(readErr, closeErr error) error {
if r.ctx.Err() != nil || errors.Is(readErr, context.Canceled) || errors.Is(readErr, context.DeadlineExceeded) {
return classifyBodyReadError(r.ctx, readErr, readErr)
}
if _, typed := errs.ProblemOf(readErr); typed {
return readErr
}
cause := readErr
if closeErr != nil {
cause = errors.Join(readErr, closeErr)
}
if errors.Is(readErr, io.ErrUnexpectedEOF) {
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
"range response body ended after %d of the %d bytes its framing declared", r.chunkRead, r.chunkWant).
WithRetryable().
WithHint("retry the download; discard any partial destination output").
WithCause(cause)
}
return classifyBodyReadError(r.ctx, readErr, cause)
}
func (r *sequentialPartReader) waitBeforeRetry() error {
delay := addRetryJitter(exponentialDelay(r.retryDelay, r.partRetries-1))
return r.retryWait.wait(r.ctx, delay, r.retryErr)
}
func (r *sequentialPartReader) closeAfter(primary *errs.NetworkError) error {
if r.current == nil {
return primary
}
closeErr := r.current.Close()
r.current = nil
if closeErr != nil {
return primary.WithCause(closeErr)
}
return primary
}
func (r *sequentialPartReader) Close() error {
if r.closed {
return nil
}
r.closed = true
if r.current == nil {
return nil
}
err := r.current.Close()
r.current = nil
return err
}
func unexpectedStatus(status int) error {
return protocolError("transport returned unexpected HTTP status %d", status).WithCode(status)
}
func protocolError(format string, args ...any) *errs.NetworkError {
return errs.NewNetworkError(errs.SubtypeNetworkProtocol, format, args...)
}
func representationChanged(format string, args ...any) *errs.NetworkError {
return errs.NewNetworkError(errs.SubtypeNetworkRepresentationChanged, format, args...).
WithRetryable().
WithHint("run the command again to download the current version")
}
// responseLimit bounds amplification while allowing smaller server ranges.
func responseLimit(totalSize, chunkSize int64) int {
expected := (totalSize-1)/chunkSize + 1
const (
floor = int64(64)
multiplier = int64(4)
)
maxInt := int64(^uint(0) >> 1)
if expected > maxInt/multiplier {
return int(maxInt)
}
limit := expected * multiplier
if limit < floor {
limit = floor
}
return int(limit)
}
// contentRange is a parsed `Content-Range: bytes start-end/total` header.
type contentRange struct {
start int64
end int64
total int64
}
func (cr contentRange) String() string {
return fmt.Sprintf("bytes %d-%d/%d", cr.start, cr.end, cr.total)
}
func (cr contentRange) length() int64 {
return cr.end - cr.start + 1
}
func parseContentRange(header string) (contentRange, error) {
header = strings.TrimSpace(header)
if header == "" {
return contentRange{}, fmt.Errorf("content-range is empty")
}
unit, spec, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(unit, "bytes") {
return contentRange{}, fmt.Errorf("unsupported content-range: %q", header)
}
parts := strings.SplitN(strings.TrimSpace(spec), "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || parts[0] == "*" {
return contentRange{}, fmt.Errorf("unsupported content-range: %q", header)
}
if parts[1] == "*" {
return contentRange{}, fmt.Errorf("unknown total size in content-range: %q", header)
}
bounds := strings.SplitN(parts[0], "-", 2)
if len(bounds) != 2 || bounds[0] == "" || bounds[1] == "" {
return contentRange{}, fmt.Errorf("unsupported content-range: %q", header)
}
start, err := strconv.ParseInt(bounds[0], 10, 64)
if err != nil {
return contentRange{}, fmt.Errorf("parse range start: %w", err)
}
end, err := strconv.ParseInt(bounds[1], 10, 64)
if err != nil {
return contentRange{}, fmt.Errorf("parse range end: %w", err)
}
total, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return contentRange{}, fmt.Errorf("parse total size: %w", err)
}
if total <= 0 {
return contentRange{}, fmt.Errorf("invalid total size: %d", total)
}
if start < 0 || end < 0 {
return contentRange{}, fmt.Errorf("invalid negative content range: %d-%d", start, end)
}
if start > end {
return contentRange{}, fmt.Errorf("invalid content range: start %d is after end %d", start, end)
}
if end >= total {
return contentRange{}, fmt.Errorf("invalid content range: end %d is outside total %d", end, total)
}
return contentRange{start: start, end: end, total: total}, nil
}
// strongETag returns a validator suitable for If-Range.
func strongETag(header http.Header) (string, bool) {
values := header.Values("ETag")
if len(values) != 1 {
return "", false
}
tag := strings.TrimSpace(values[0])
if len(tag) < 2 || tag[0] != '"' || tag[len(tag)-1] != '"' {
return "", false
}
for i := 1; i < len(tag)-1; i++ {
if c := tag[i]; c != 0x21 && !(c >= 0x23 && c <= 0x7E) && c < 0x80 {
return "", false
}
}
return tag, true
}