[PR] avformat/libcurl: various improvements (PR #23934)

Niklas Haas via ffmpeg-devel <[email protected]> Mon, 27 Jul 2026 22:34:49 -0000
Newsgroups gmane.comp.video.ffmpeg.devel
Message-ID <178519169062.51.14086580488374677820@29965ddac10e>
PR #23934 opened by Niklas Haas (haasn)
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23934
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/23934.patch

I can split this up into smaller PRs if that makes it easier to review, just let me know.

By the way, I double-checked the `c->is_initial` flow but it matches the intent exactly - once we hit the end of a given request and start the next, initial is set to 0. Note that this relies on the seek happening *before* the request was fully read, which triggers either a stream reset, a reconnect or (as of this PR) a short seek.

So there's no change to be made there.


>From efa65806965525df43a795c61dec89444c637304 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Wed, 24 Jun 2026 19:21:41 +0200
Subject: [PATCH 01/16] avformat/libcurl: implement -short_seek_size

Since libcurl.c doesn't have to care about the exact number of bytes read,
we just perform the seek but defer the actual `start_request()` call until
the next on_done() callback, while simply discarding all data read in the
meantime.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 doc/protocols.texi    |  8 ++++++++
 libavformat/libcurl.c | 42 ++++++++++++++++++++++++++++++++++++++----
 2 files changed, 46 insertions(+), 4 deletions(-)

diff --git a/doc/protocols.texi b/doc/protocols.texi
index f7e65b1c9d..1650cbf983 100644
--- a/doc/protocols.texi
+++ b/doc/protocols.texi
@@ -1166,9 +1166,17 @@ read request larger than this size (without a seek in between), after which
 the implementation will continue using requests as usual. Disabled (set to 0)
 by default.
 
+Note that if enabling this option, it's strongly recommended to also set
+@option{short_seek_size} to the same value or higher.
+
 @item max_retries
 Maximum number of retries after a recoverable error on a seekable transfer.
 Default is @code{5}.
+
+@item short_seek_size
+Set the threshold, in bytes, for when a readahead should be preferred over a seek and
+new HTTP request. This is useful, for example, to make sure the same connection
+is used for reading large video packets with small audio packets in between.
 @end table
 
 For more information see: @url{https://curl.se/libcurl/}.
diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index bb5fa0f002..e3ae42558c 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -120,6 +120,7 @@ struct CurlContext {
     int64_t         buffer_size;
     int64_t         request_size;
     int64_t         initial_request_size;
+    int64_t         short_seek_size;
     int             max_retries;
 
     int64_t         logical_pos; /* next byte url_read() will return, caller side */
@@ -131,6 +132,7 @@ struct CurlContext {
     int64_t         request_end;     /* expected end of request, or -1 if unknown */
     int             retry_count;     /* consecutive recoverable failures */
     int             is_initial;      /* using reduced request size */
+    int             seek_queued;     /* soft seeking; drain remaining bytes until done */
 
     /* Per-response-block header scratch, loop thread only. */
     int             hdr_accept_ranges;
@@ -212,6 +214,11 @@ static size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdat
         return CURL_WRITEFUNC_ERROR;
     }
 
+    if (c->seek_queued) {
+        pthread_mutex_unlock(&c->mutex);
+        return bytes; /* discard */
+    }
+
     space = av_fifo_can_write(c->fifo);
     if (space < bytes) {
         /* pause the transfer and wait for the consumer to drain. */
@@ -482,6 +489,13 @@ static void on_done(CurlContext *c, CURLcode code)
         return;
     }
 
+    if (c->seek_queued && !aborted) {
+        /* previous soft seek drain finished; can start new request now */
+        c->seek_queued = 0;
+        start_request(c);
+        return;
+    }
+
     if (code == CURLE_OK && !aborted && c->stream_ok) {
         c->retry_count = 0;
         int64_t file_end = c->content_size > 0 ? c->content_size - 1 : -1;
@@ -545,7 +559,15 @@ static void execute_command(CurlLoop *loop, CurlCmd *cmd)
         curl_easy_pause(c->easy, CURLPAUSE_CONT);
         break;
     case CMD_SEEK:
-        if (c->active) {
+        if (c->active && c->request_end >= 0 && c->short_seek_size > 0 &&
+            c->request_end - c->request_start + 1 - c->request_received <= c->short_seek_size)
+        {
+            c->seek_queued = 1;
+            if (c->paused) {
+                curl_easy_pause(c->easy, CURLPAUSE_CONT);
+                c->paused = 0;
+            }
+        } else if (c->active && !c->seek_queued) {
             curl_multi_remove_handle(loop->multi, c->easy);
             c->active = 0;
         }
@@ -555,9 +577,11 @@ static void execute_command(CurlLoop *loop, CurlCmd *cmd)
         c->eof    = 0;
         c->error  = 0;
         pthread_mutex_unlock(&c->mutex);
-        c->request_start = cmd->pos;
-        c->retry_count   = 0;
-        start_request(c);
+        c->request_start    = cmd->pos;
+        c->request_received = 0;
+        c->retry_count      = 0;
+        if (!c->seek_queued)
+            start_request(c);
         break;
     }
 }
@@ -1168,6 +1192,14 @@ static int libcurl_close(URLContext *h)
     return 0;
 }
 
+static int libcurl_get_short_seek(URLContext *h)
+{
+    CurlContext *c = h->priv_data;
+    if (c->short_seek_size >= 1)
+        return c->short_seek_size;
+    return AVERROR(ENOSYS);
+}
+
 #define OFFSET(x) offsetof(CurlContext, x)
 #define D AV_OPT_FLAG_DECODING_PARAM
 #define E AV_OPT_FLAG_ENCODING_PARAM
@@ -1201,6 +1233,7 @@ static const AVOption options[] = {
         { "2-prior-knowledge", "HTTP/2 without an upgrade handshake",   0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE },   0, 0, D, .unit = "http_version" },
         { "3",                 "HTTP/3, fall back to earlier versions", 0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_3 },                   0, 0, D, .unit = "http_version" },
         { "3only",             "HTTP/3 only",                           0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_3ONLY },               0, 0, D, .unit = "http_version" },
+    { "short_seek_size", "threshold to favor readahead over seek", OFFSET(short_seek_size), AV_OPT_TYPE_INT64, { .i64 = 0 }, 0, INT64_MAX, D },
     { NULL }
 };
 
@@ -1217,6 +1250,7 @@ const URLProtocol ff_libcurl_protocol = {
     .url_read        = libcurl_read,
     .url_seek        = libcurl_seek,
     .url_close       = libcurl_close,
+    .url_get_short_seek = libcurl_get_short_seek,
     .priv_data_size  = sizeof(CurlContext),
     .priv_data_class = &libcurl_context_class,
     .flags           = URL_PROTOCOL_FLAG_NETWORK,
-- 
2.52.0


>From 7fb2ced3c00390e0e69b1e920169a132d025ab87 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 25 Jun 2026 13:54:35 +0200
Subject: [PATCH 02/16] avformat/libcurl: consolidate s->error and s->eof

s->eof can just be AVERROR_EOF.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 44 +++++++++++++++++++------------------------
 1 file changed, 19 insertions(+), 25 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index e3ae42558c..11cf6418d2 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -152,8 +152,7 @@ struct CurlContext {
     pthread_cond_t  cond;
     AVFifo         *fifo;
     int             paused;      /* write callback paused, FIFO was full */
-    int             eof;         /* producer delivered all data */
-    int             error;       /* AVERROR for an unrecoverable failure, or 0 */
+    int             status;      /* current stream status (AVERROR code) */
     int             aborted;     /* transfer should stop (open was interrupted) */
 };
 
@@ -316,8 +315,8 @@ static size_t header_callback(char *ptr, size_t size, size_t nitems, void *userd
                    "with offset %"PRId64" (expected %"PRId64")\n",
                    content_start, c->request_start);
             c->stream_ok = 0;
-            if (!c->error)
-                c->error = AVERROR(EIO);
+            if (!c->status)
+                c->status = AVERROR(EIO);
             pthread_cond_broadcast(&c->cond);
             pthread_mutex_unlock(&c->mutex);
             return len;
@@ -367,8 +366,8 @@ static size_t header_callback(char *ptr, size_t size, size_t nitems, void *userd
             c->seekable = c->seekable_opt;
     } else {
         c->stream_ok = 0;
-        if (!c->error)
-            c->error = ff_http_averror(status, AVERROR(EIO));
+        if (!c->status)
+            c->status = ff_http_averror(status, AVERROR(EIO));
     }
     c->probed = 1;
     pthread_cond_broadcast(&c->cond);
@@ -424,8 +423,8 @@ static void start_request(CurlContext *c)
                curl_multi_strerror(res));
         c->active = 0;
         pthread_mutex_lock(&c->mutex);
-        if (!c->error)
-            c->error = AVERROR(EIO);
+        if (!c->status)
+            c->status = AVERROR(EIO);
         pthread_cond_broadcast(&c->cond);
         pthread_mutex_unlock(&c->mutex);
     }
@@ -466,8 +465,8 @@ static void on_done(CurlContext *c, CURLcode code)
     received = c->request_received;
     /* Advance past delivered bytes so a retry or seek resumes at the right offset. */
     if (received > INT64_MAX - c->request_start) {
-        if (!c->error)
-            c->error = AVERROR(EIO);
+        if (!c->status)
+            c->status = AVERROR(EIO);
         received = 0;
         aborted  = 1;
         pthread_cond_broadcast(&c->cond);
@@ -482,8 +481,8 @@ static void on_done(CurlContext *c, CURLcode code)
         pthread_mutex_lock(&c->mutex);
         c->probed    = 1;
         c->stream_ok = 0;
-        if (!c->error)
-            c->error = curlcode_to_averror(code);
+        if (!c->status)
+            c->status = curlcode_to_averror(code);
         pthread_cond_broadcast(&c->cond);
         pthread_mutex_unlock(&c->mutex);
         return;
@@ -507,7 +506,7 @@ static void on_done(CurlContext *c, CURLcode code)
             return;
         }
         pthread_mutex_lock(&c->mutex);
-        c->eof = 1;
+        c->status = AVERROR_EOF;
         pthread_cond_broadcast(&c->cond);
         pthread_mutex_unlock(&c->mutex);
         return;
@@ -526,8 +525,8 @@ static void on_done(CurlContext *c, CURLcode code)
 
     if (!aborted) {
         pthread_mutex_lock(&c->mutex);
-        if (!c->error)
-            c->error = curlcode_to_averror(code);
+        if (!c->status)
+            c->status = curlcode_to_averror(code);
         pthread_cond_broadcast(&c->cond);
         pthread_mutex_unlock(&c->mutex);
     }
@@ -574,8 +573,7 @@ static void execute_command(CurlLoop *loop, CurlCmd *cmd)
         pthread_mutex_lock(&c->mutex);
         av_fifo_reset2(c->fifo);
         c->paused = 0;
-        c->eof    = 0;
-        c->error  = 0;
+        c->status = 0;
         pthread_mutex_unlock(&c->mutex);
         c->request_start    = cmd->pos;
         c->request_received = 0;
@@ -980,7 +978,7 @@ static int wait_for_probe(CurlContext *c)
     int ret = 0;
 
     pthread_mutex_lock(&c->mutex);
-    while (!c->probed && !c->error) {
+    while (!c->probed && !c->status) {
         if (ff_check_interrupt(&h->interrupt_callback)) {
             c->aborted = 1;
             ret = AVERROR_EXIT;
@@ -990,7 +988,7 @@ static int wait_for_probe(CurlContext *c)
     }
     if (!ret) {
         if (!c->stream_ok)
-            ret = c->error ? c->error : AVERROR(EIO);
+            ret = c->status ? c->status : AVERROR(EIO);
     }
     pthread_mutex_unlock(&c->mutex);
 
@@ -1096,12 +1094,8 @@ static int libcurl_read(URLContext *h, unsigned char *buf, int size)
                 curl_dispatch(c->loop, CMD_UNPAUSE, c, 0, 0);
             return n;
         }
-        if (c->error) {
-            ret = c->error;
-            break;
-        }
-        if (c->eof) {
-            ret = AVERROR_EOF;
+        if (c->status) {
+            ret = c->status;
             break;
         }
         if (nonblock) {
-- 
2.52.0


>From 42a961a23590e7e83920e3e3143ed0bdf783f791 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Tue, 28 Jul 2026 00:32:05 +0200
Subject: [PATCH 03/16] avutil/error: add HTTP 416 Range Not Satisfiable

This is returned by HTTPds for files with unknown/growing filesize,
when trying to read past what's already available. Needed to implement
support for such streaming-file / incremental scenarios, both internally,
and as a user-facing failure condition when e.g. the maximum retry delay
is reached.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavutil/error.c   | 1 +
 libavutil/error.h   | 1 +
 libavutil/version.h | 2 +-
 3 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/libavutil/error.c b/libavutil/error.c
index 2c9f0028bd..0e6395d5c4 100644
--- a/libavutil/error.c
+++ b/libavutil/error.c
@@ -55,6 +55,7 @@
     E(HTTP_FORBIDDEN,           "Server returned 403 Forbidden (access denied)")                \
     E(HTTP_NOT_FOUND,           "Server returned 404 Not Found")                                \
     E(HTTP_TOO_MANY_REQUESTS,   "Server returned 429 Too Many Requests")                        \
+    E(HTTP_RANGE_NOT_SATISFIABLE,"Server returned 416 Range Not Satisfiable")                   \
     E(HTTP_OTHER_4XX,           "Server returned 4XX Client Error, but not one of 40{0,1,3,4}") \
     E(HTTP_SERVER_ERROR,        "Server returned 5XX Server Error reply")                       \
 
diff --git a/libavutil/error.h b/libavutil/error.h
index 1efa86c4c1..148717a907 100644
--- a/libavutil/error.h
+++ b/libavutil/error.h
@@ -79,6 +79,7 @@
 #define AVERROR_HTTP_UNAUTHORIZED  FFERRTAG(0xF8,'4','0','1')
 #define AVERROR_HTTP_FORBIDDEN     FFERRTAG(0xF8,'4','0','3')
 #define AVERROR_HTTP_NOT_FOUND     FFERRTAG(0xF8,'4','0','4')
+#define AVERROR_HTTP_RANGE_NOT_SATISFIABLE FFERRTAG(0xF8,'4','1','6')
 #define AVERROR_HTTP_TOO_MANY_REQUESTS FFERRTAG(0xF8,'4','2','9')
 #define AVERROR_HTTP_OTHER_4XX     FFERRTAG(0xF8,'4','X','X')
 #define AVERROR_HTTP_SERVER_ERROR  FFERRTAG(0xF8,'5','X','X')
diff --git a/libavutil/version.h b/libavutil/version.h
index d5bf20cf89..4f6fa46314 100644
--- a/libavutil/version.h
+++ b/libavutil/version.h
@@ -80,7 +80,7 @@
 
 #define LIBAVUTIL_VERSION_MAJOR  61
 #define LIBAVUTIL_VERSION_MINOR   5
-#define LIBAVUTIL_VERSION_MICRO 100
+#define LIBAVUTIL_VERSION_MICRO 101
 
 #define LIBAVUTIL_VERSION_INT   AV_VERSION_INT(LIBAVUTIL_VERSION_MAJOR, \
                                                LIBAVUTIL_VERSION_MINOR, \
-- 
2.52.0


>From 6cce72be42912dd86120eb9f3719383c789795ca Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 16:23:43 +0200
Subject: [PATCH 04/16] avformat/libcurl: early-exit on_done() when aborted

Simplifies the following logic by not having to embed a check for !aborted
in every conditional branch.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 22 ++++++++++++----------
 1 file changed, 12 insertions(+), 10 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 11cf6418d2..32fbb0ddb8 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -488,14 +488,17 @@ static void on_done(CurlContext *c, CURLcode code)
         return;
     }
 
-    if (c->seek_queued && !aborted) {
+    if (aborted)
+        return;
+
+    if (c->seek_queued) {
         /* previous soft seek drain finished; can start new request now */
         c->seek_queued = 0;
         start_request(c);
         return;
     }
 
-    if (code == CURLE_OK && !aborted && c->stream_ok) {
+    if (code == CURLE_OK && c->stream_ok) {
         c->retry_count = 0;
         int64_t file_end = c->content_size > 0 ? c->content_size - 1 : -1;
         if (c->end_off > 0)
@@ -513,7 +516,7 @@ static void on_done(CurlContext *c, CURLcode code)
     }
 
     /* Resume seekable transfers after a recoverable error. */
-    if (!aborted && c->seekable && is_recoverable(code) &&
+    if (c->seekable && is_recoverable(code) &&
         c->retry_count < c->max_retries) {
         c->retry_count++;
         c->loop->num_retries++;
@@ -523,13 +526,12 @@ static void on_done(CurlContext *c, CURLcode code)
         return;
     }
 
-    if (!aborted) {
-        pthread_mutex_lock(&c->mutex);
-        if (!c->status)
-            c->status = curlcode_to_averror(code);
-        pthread_cond_broadcast(&c->cond);
-        pthread_mutex_unlock(&c->mutex);
-    }
+    /* Unhandled generic curl error */
+    pthread_mutex_lock(&c->mutex);
+    if (!c->status)
+        c->status = curlcode_to_averror(code);
+    pthread_cond_broadcast(&c->cond);
+    pthread_mutex_unlock(&c->mutex);
 }
 
 /* ------------------------------------------------------------------------- */
-- 
2.52.0


>From d7e99a2d91bb2a7ce6d8431f126b948d38654132 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 16:19:55 +0200
Subject: [PATCH 05/16] avformat/libcurl: log all unexpected curl errors

Excludes the case of !c->stream_ok (e.g. the result of an HTTP error),
since in this case the actual curl errore code is likely just
CURLE_WRITE_ERROR.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 32fbb0ddb8..77d4935d53 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -515,13 +515,16 @@ static void on_done(CurlContext *c, CURLcode code)
         return;
     }
 
+    if (c->stream_ok)
+        av_log(c->h, AV_LOG_WARNING, "%s\n", curl_easy_strerror(code));
+
     /* Resume seekable transfers after a recoverable error. */
     if (c->seekable && is_recoverable(code) &&
         c->retry_count < c->max_retries) {
         c->retry_count++;
         c->loop->num_retries++;
-        av_log(c->h, AV_LOG_WARNING, "%s, retrying (#%d) from %"PRId64"\n",
-               curl_easy_strerror(code), c->retry_count, c->request_start);
+        av_log(c->h, AV_LOG_WARNING, "Retrying (#%d) from %"PRId64"\n",
+               c->retry_count, c->request_start);
         start_request(c);
         return;
     }
-- 
2.52.0


>From 8f1a209f3c4b74d9896521908d7f4ff03c553418 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 16:45:58 +0200
Subject: [PATCH 06/16] avformat/libcurl: log number of errors, not retries

This is arguably more informative, and also easier to account for, than
the retries (which are already indirectly reflected in the number of
requests and connections).

More importantly, this avoids getting in the way of us refactoring the
retry mechanism.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 14 +++++++++-----
 1 file changed, 9 insertions(+), 5 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 77d4935d53..cdee8fb2d5 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -87,7 +87,7 @@ typedef struct CurlLoop {
     int            num_connections;
     int            num_redirects;
     int            num_requests;
-    int            num_retries;
+    int            num_errors;
 } CurlLoop;
 
 struct CurlContext {
@@ -314,6 +314,7 @@ static size_t header_callback(char *ptr, size_t size, size_t nitems, void *userd
             av_log(c->h, AV_LOG_ERROR, "Server sent back unexpected reply "
                    "with offset %"PRId64" (expected %"PRId64")\n",
                    content_start, c->request_start);
+            c->loop->num_errors++;
             c->stream_ok = 0;
             if (!c->status)
                 c->status = AVERROR(EIO);
@@ -365,6 +366,7 @@ static size_t header_callback(char *ptr, size_t size, size_t nitems, void *userd
         if (c->seekable_opt >= 0)
             c->seekable = c->seekable_opt;
     } else {
+        c->loop->num_errors++;
         c->stream_ok = 0;
         if (!c->status)
             c->status = ff_http_averror(status, AVERROR(EIO));
@@ -483,6 +485,7 @@ static void on_done(CurlContext *c, CURLcode code)
         c->stream_ok = 0;
         if (!c->status)
             c->status = curlcode_to_averror(code);
+        c->loop->num_errors++;
         pthread_cond_broadcast(&c->cond);
         pthread_mutex_unlock(&c->mutex);
         return;
@@ -515,14 +518,15 @@ static void on_done(CurlContext *c, CURLcode code)
         return;
     }
 
-    if (c->stream_ok)
+    if (c->stream_ok) {
         av_log(c->h, AV_LOG_WARNING, "%s\n", curl_easy_strerror(code));
+        c->loop->num_errors++;
+    }
 
     /* Resume seekable transfers after a recoverable error. */
     if (c->seekable && is_recoverable(code) &&
         c->retry_count < c->max_retries) {
         c->retry_count++;
-        c->loop->num_retries++;
         av_log(c->h, AV_LOG_WARNING, "Retrying (#%d) from %"PRId64"\n",
                c->retry_count, c->request_start);
         start_request(c);
@@ -736,8 +740,8 @@ static void print_statistics(CurlLoop *loop)
            loop->total_bytes, time * 1e3, avg / 1e3);
 
     av_log(avfc, AV_LOG_VERBOSE,
-           "libcurl: %d connections, %d redirects, %d requests, %d retries\n",
-           loop->num_connections, loop->num_redirects, loop->num_requests, loop->num_retries);
+           "libcurl: %d connections, %d redirects, %d requests, %d errors\n",
+           loop->num_connections, loop->num_redirects, loop->num_requests, loop->num_errors);
 }
 
 static void curl_loop_destroy(CurlLoop *loop)
-- 
2.52.0


>From f032afb41abe11c193fb77398d2387966b04d0ce Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 17:08:27 +0200
Subject: [PATCH 07/16] avformat/libcurl: move retry handling to the URL thread

Instead of the loop worker immediately retrying requests, this change moves
the retry accounting logic to the calling thread instead. The worker thread
signals AVERROR(EAGAIN) to indicate that the request should be retried by
the caller.

It's worth pointing out that this value can never reach wait_for_probe() in
libcurl_open(), because that function only returns if !c->stream_ok, at
which point we would already have bailed out with some other error. I note
this simply to point out why this commit does not need to add any retry
handling to libcurl_open() yet.

The main motivation here is that it allows us to add a configurable delay
between retries, e.g. for exponential backoff or respecting server advertised
Retry-After headers.

There is some ambiquity about when exactly we want to reset retry_count, with
the options roughly being:

1. After fully reading a successful request to the end (old behavior)
2. After successfully reading any number of bytes (new behavior)
3. Never / only on explicit ffurl_seek()

I think that both 2 and 3 are defensible, but 1 is arguably the only one
that's not really useful; so this patch also represents a mild improvement
in that regard - the retry counter only starts ticking up if the request
fails several times in a row.

The reason I think the old behavior is not useful here is because the rate
of *requests* is a bit arbitrary and depends on options like -request_size.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 54 ++++++++++++++++++++++++++++++++++---------
 1 file changed, 43 insertions(+), 11 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index cdee8fb2d5..8bcc060bc7 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -123,14 +123,15 @@ struct CurlContext {
     int64_t         short_seek_size;
     int             max_retries;
 
-    int64_t         logical_pos; /* next byte url_read() will return, caller side */
+    /* URL thread bookkeeping, not touched by loop thread */
+    int64_t         logical_pos;    /* next byte url_read() will return, caller side */
+    int             retry_count;    /* consecutive recoverable failures */
 
     /* Producer bookkeeping, touched only by the loop thread. */
     int             active;          /* currently added to the multi */
     int64_t         request_start;   /* absolute offset the current request began at */
     int64_t         request_received;/* bytes delivered in the current request */
     int64_t         request_end;     /* expected end of request, or -1 if unknown */
-    int             retry_count;     /* consecutive recoverable failures */
     int             is_initial;      /* using reduced request size */
     int             seek_queued;     /* soft seeking; drain remaining bytes until done */
 
@@ -502,7 +503,6 @@ static void on_done(CurlContext *c, CURLcode code)
     }
 
     if (code == CURLE_OK && c->stream_ok) {
-        c->retry_count = 0;
         int64_t file_end = c->content_size > 0 ? c->content_size - 1 : -1;
         if (c->end_off > 0)
             file_end = FFMIN(file_end, c->end_off - 1);
@@ -524,12 +524,12 @@ static void on_done(CurlContext *c, CURLcode code)
     }
 
     /* Resume seekable transfers after a recoverable error. */
-    if (c->seekable && is_recoverable(code) &&
-        c->retry_count < c->max_retries) {
-        c->retry_count++;
-        av_log(c->h, AV_LOG_WARNING, "Retrying (#%d) from %"PRId64"\n",
-               c->retry_count, c->request_start);
-        start_request(c);
+    if (c->seekable && is_recoverable(code)) {
+        pthread_mutex_lock(&c->mutex);
+        if (!c->status)
+            c->status = AVERROR(EAGAIN);
+        pthread_cond_broadcast(&c->cond);
+        pthread_mutex_unlock(&c->mutex);
         return;
     }
 
@@ -586,7 +586,6 @@ static void execute_command(CurlLoop *loop, CurlCmd *cmd)
         pthread_mutex_unlock(&c->mutex);
         c->request_start    = cmd->pos;
         c->request_received = 0;
-        c->retry_count      = 0;
         if (!c->seek_queued)
             start_request(c);
         break;
@@ -1004,6 +1003,32 @@ static int wait_for_probe(CurlContext *c)
     return ret;
 }
 
+static int retry_request(URLContext *h)
+{
+    CurlContext *c = h->priv_data;
+
+    if (c->retry_count >= c->max_retries) {
+        av_log(h, AV_LOG_ERROR, "Maximum number of retries (%d) reached\n",
+               c->max_retries);
+        return AVERROR(EIO);
+    }
+
+    c->retry_count++;
+    av_log(h, AV_LOG_WARNING, "Retrying (#%d) from %"PRId64"\n",
+           c->retry_count, c->logical_pos);
+
+    /**
+     * Use a synchronous request to ensure that the seek is registered, and
+     * the reset of c->state is observable, before the next libcurl_read()
+     * call, otherwise this might hit the exact same retry path a second time.
+     */
+    int ret = curl_dispatch(c->loop, CMD_SEEK, c, c->logical_pos, 1);
+    if (ret < 0)
+        return ret;
+
+    return AVERROR(EAGAIN); /* allow caller to handle interrupts and retry */
+}
+
 static int libcurl_open(URLContext *h, const char *url, int flags,
                         AVDictionary **options)
 {
@@ -1067,6 +1092,8 @@ static int libcurl_open(URLContext *h, const char *url, int flags,
         goto fail;
 
     ret = wait_for_probe(c);
+    if (ret == AVERROR(EAGAIN))
+        ret = 0; /* this will be handled by the next libcurl_read() call */
     if (ret < 0)
         goto fail;
 
@@ -1097,13 +1124,17 @@ static int libcurl_read(URLContext *h, unsigned char *buf, int size)
             av_fifo_read(c->fifo, buf, n);
             /* Resume a paused transfer once the FIFO is at least half empty. */
             unpause = c->paused && av_fifo_can_write(c->fifo) * 2 >= c->buffer_size;
+            c->retry_count = 0;
             c->logical_pos += n;
             pthread_mutex_unlock(&c->mutex);
             if (unpause)
                 curl_dispatch(c->loop, CMD_UNPAUSE, c, 0, 0);
             return n;
         }
-        if (c->status) {
+        if (c->status == AVERROR(EAGAIN)) {
+            pthread_mutex_unlock(&c->mutex);
+            return retry_request(h);
+        } else if (c->status) {
             ret = c->status;
             break;
         }
@@ -1165,6 +1196,7 @@ static int64_t libcurl_seek(URLContext *h, int64_t pos, int whence)
      * surfaces on the following url_read(). */
     curl_dispatch(c->loop, CMD_SEEK, c, newpos, 1);
     c->logical_pos = newpos;
+    c->retry_count = 0;
 
     return newpos;
 }
-- 
2.52.0


>From 84cea92c0e671f1c104b811fb25dbba8e129dec7 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:21:19 +0200
Subject: [PATCH 08/16] avformat/libcurl: allow retrying initial request

Relaxes the retry logic and adds a retry loop to libcurl_open() as well,
in case the *initial* request fails (e.g. due to unexpected network event
before receiving all headers).

This does require a bit more strictness on what type of errors we allow
recovering from during the initial connection attempt. In particular,
we probably don't want to retry on resolve/connect failure.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 33 ++++++++++++++++++++++-----------
 1 file changed, 22 insertions(+), 11 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 8bcc060bc7..0f3173d03b 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -179,19 +179,23 @@ static int curlcode_to_averror(CURLcode code)
     }
 }
 
-static int is_recoverable(CURLcode code)
+static int is_recoverable(CurlContext *c, CURLcode code)
 {
+    if (!c->seekable && c->request_start > 0)
+        return 0;
+
     switch (code) {
     case CURLE_RECV_ERROR:
     case CURLE_SEND_ERROR:
     case CURLE_PARTIAL_FILE:
     case CURLE_OPERATION_TIMEDOUT:
     case CURLE_GOT_NOTHING:
-    case CURLE_COULDNT_CONNECT:
-    case CURLE_COULDNT_RESOLVE_HOST:
     case CURLE_HTTP2:
     case CURLE_HTTP2_STREAM:
         return 1;
+    case CURLE_COULDNT_CONNECT:
+    case CURLE_COULDNT_RESOLVE_HOST:
+        return c->request_start > 0;
     default:
         return 0;
     }
@@ -484,8 +488,12 @@ static void on_done(CurlContext *c, CURLcode code)
         pthread_mutex_lock(&c->mutex);
         c->probed    = 1;
         c->stream_ok = 0;
-        if (!c->status)
-            c->status = curlcode_to_averror(code);
+        if (!c->status) {
+            if (is_recoverable(c, code))
+                c->status = AVERROR(EAGAIN);
+            else
+                c->status = curlcode_to_averror(code);
+        }
         c->loop->num_errors++;
         pthread_cond_broadcast(&c->cond);
         pthread_mutex_unlock(&c->mutex);
@@ -524,7 +532,7 @@ static void on_done(CurlContext *c, CURLcode code)
     }
 
     /* Resume seekable transfers after a recoverable error. */
-    if (c->seekable && is_recoverable(code)) {
+    if (is_recoverable(c, code)) {
         pthread_mutex_lock(&c->mutex);
         if (!c->status)
             c->status = AVERROR(EAGAIN);
@@ -1091,11 +1099,14 @@ static int libcurl_open(URLContext *h, const char *url, int flags,
     if (ret < 0)
         goto fail;
 
-    ret = wait_for_probe(c);
-    if (ret == AVERROR(EAGAIN))
-        ret = 0; /* this will be handled by the next libcurl_read() call */
-    if (ret < 0)
-        goto fail;
+    do {
+        ret = wait_for_probe(c);
+        if (ret == AVERROR(EAGAIN)) {
+            c->probed = 0;
+            ret = retry_request(h);
+        } else if (ret < 0)
+            goto fail;
+    } while (ret == AVERROR(EAGAIN));
 
     pthread_mutex_lock(&c->mutex);
     h->is_streamed = !c->seekable;
-- 
2.52.0


>From 6cf5a8174356430848bc8ba5b097ac00b56d8791 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:27:52 +0200
Subject: [PATCH 09/16] avformat/libcurl: also log errors without successful
 data transfer

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 23 +++++++++++++----------
 1 file changed, 13 insertions(+), 10 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 0f3173d03b..f537d46d38 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -737,18 +737,21 @@ fail:
 static void print_statistics(CurlLoop *loop)
 {
     AVFormatContext *avfc = loop->avfc;
-    if (!loop->total_bytes)
-        return;
 
-    double time = (double) loop->total_time_us / 1000000.0;
-    double avg  = time ? loop->total_bytes / time : 0;
-    av_log(avfc, AV_LOG_VERBOSE,
-           "libcurl: Overall %"PRId64" bytes received in %.0f ms = %.0f kB/s\n",
-           loop->total_bytes, time * 1e3, avg / 1e3);
+    if (loop->total_bytes) {
+        double time = (double) loop->total_time_us / 1000000.0;
+        double avg  = time ? loop->total_bytes / time : 0;
+        av_log(avfc, AV_LOG_VERBOSE,
+               "libcurl: Overall %"PRId64" bytes received in %.0f ms = %.0f kB/s\n",
+               loop->total_bytes, time * 1e3, avg / 1e3);
+    }
 
-    av_log(avfc, AV_LOG_VERBOSE,
-           "libcurl: %d connections, %d redirects, %d requests, %d errors\n",
-           loop->num_connections, loop->num_redirects, loop->num_requests, loop->num_errors);
+    if (loop->num_connections || loop->num_errors) {
+        av_log(avfc, AV_LOG_VERBOSE,
+               "libcurl: %d connections, %d redirects, %d requests, %d errors\n",
+               loop->num_connections, loop->num_redirects, loop->num_requests,
+               loop->num_errors);
+    }
 }
 
 static void curl_loop_destroy(CurlLoop *loop)
-- 
2.52.0


>From 71fd25db06e6f3c3f6ead840c6862f2fcaa3f48e Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:29:08 +0200
Subject: [PATCH 10/16] avformat/libcurl: add -reconnect_on_http_error option

Parity with http.c. This piggy-backs off the previously added
mechanism for triggering a retry by just setting the stream status
to AVERROR(EAGAIN) and letting the caller deal with it.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 doc/protocols.texi    |  4 ++++
 libavformat/libcurl.c | 36 +++++++++++++++++++++++++++++++-----
 2 files changed, 35 insertions(+), 5 deletions(-)

diff --git a/doc/protocols.texi b/doc/protocols.texi
index 1650cbf983..6990d60000 100644
--- a/doc/protocols.texi
+++ b/doc/protocols.texi
@@ -1177,6 +1177,10 @@ Default is @code{5}.
 Set the threshold, in bytes, for when a readahead should be preferred over a seek and
 new HTTP request. This is useful, for example, to make sure the same connection
 is used for reading large video packets with small audio packets in between.
+
+@item reconnect_on_http_error
+A comma separated list of HTTP status codes to reconnect on. The list can
+include specific status codes (e.g. '503') or the strings '4xx' / '5xx'.
 @end table
 
 For more information see: @url{https://curl.se/libcurl/}.
diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index f537d46d38..b237bc9319 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -122,6 +122,7 @@ struct CurlContext {
     int64_t         initial_request_size;
     int64_t         short_seek_size;
     int             max_retries;
+    char           *reconnect_on_http_error;
 
     /* URL thread bookkeeping, not touched by loop thread */
     int64_t         logical_pos;    /* next byte url_read() will return, caller side */
@@ -179,7 +180,27 @@ static int curlcode_to_averror(CURLcode code)
     }
 }
 
-static int is_recoverable(CurlContext *c, CURLcode code)
+static int http_error_is_recoverable(CurlContext *c, long http_status)
+{
+    if (!c->reconnect_on_http_error || !http_status)
+        return 0;
+    if (!c->seekable && c->request_start > 0)
+        return 0;
+
+    const char *status_group = NULL;
+    if (http_status >= 400 && http_status < 500)
+        status_group = "4xx";
+    else if (http_status >= 500 && http_status < 600)
+        status_group = "5xx";
+    if (status_group && av_match_list(status_group, c->reconnect_on_http_error, ',') > 0)
+        return 1;
+
+    char http_code[4];
+    snprintf(http_code, sizeof(http_code), "%ld", http_status);
+    return av_match_list(http_code, c->reconnect_on_http_error, ',') > 0;
+}
+
+static int curl_error_is_recoverable(CurlContext *c, CURLcode code)
 {
     if (!c->seekable && c->request_start > 0)
         return 0;
@@ -373,8 +394,12 @@ static size_t header_callback(char *ptr, size_t size, size_t nitems, void *userd
     } else {
         c->loop->num_errors++;
         c->stream_ok = 0;
-        if (!c->status)
-            c->status = ff_http_averror(status, AVERROR(EIO));
+        if (!c->status) {
+            if (http_error_is_recoverable(c, status))
+                c->status = AVERROR(EAGAIN);
+            else
+                c->status = ff_http_averror(status, AVERROR(EIO));
+        }
     }
     c->probed = 1;
     pthread_cond_broadcast(&c->cond);
@@ -489,7 +514,7 @@ static void on_done(CurlContext *c, CURLcode code)
         c->probed    = 1;
         c->stream_ok = 0;
         if (!c->status) {
-            if (is_recoverable(c, code))
+            if (curl_error_is_recoverable(c, code))
                 c->status = AVERROR(EAGAIN);
             else
                 c->status = curlcode_to_averror(code);
@@ -532,7 +557,7 @@ static void on_done(CurlContext *c, CURLcode code)
     }
 
     /* Resume seekable transfers after a recoverable error. */
-    if (is_recoverable(c, code)) {
+    if (curl_error_is_recoverable(c, code)) {
         pthread_mutex_lock(&c->mutex);
         if (!c->status)
             c->status = AVERROR(EAGAIN);
@@ -1283,6 +1308,7 @@ static const AVOption options[] = {
         { "3",                 "HTTP/3, fall back to earlier versions", 0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_3 },                   0, 0, D, .unit = "http_version" },
         { "3only",             "HTTP/3 only",                           0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_3ONLY },               0, 0, D, .unit = "http_version" },
     { "short_seek_size", "threshold to favor readahead over seek", OFFSET(short_seek_size), AV_OPT_TYPE_INT64, { .i64 = 0 }, 0, INT64_MAX, D },
+    { "reconnect_on_http_error", "list of http status codes to reconnect on", OFFSET(reconnect_on_http_error), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, D },
     { NULL }
 };
 
-- 
2.52.0


>From 8363b1a03cb3ca26f3003317532a055aaf24a0ff Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:31:27 +0200
Subject: [PATCH 11/16] avformat/libcurl: add -reconnect_streamed

Parity with http.c. This will just result in a non-range request, which
is analogous to the situation in http.c. It's up to the server to send
meaningful data in this case.

It's worth pointing out that c->request_start will still continue
ticking up in this case, which only affects the position reported in
the reconnection message since all other code paths are additionally
guarded by c->seekable.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 doc/protocols.texi    | 3 +++
 libavformat/libcurl.c | 6 ++++--
 2 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/doc/protocols.texi b/doc/protocols.texi
index 6990d60000..9ae0fbc936 100644
--- a/doc/protocols.texi
+++ b/doc/protocols.texi
@@ -1181,6 +1181,9 @@ is used for reading large video packets with small audio packets in between.
 @item reconnect_on_http_error
 A comma separated list of HTTP status codes to reconnect on. The list can
 include specific status codes (e.g. '503') or the strings '4xx' / '5xx'.
+
+@item reconnect_streamed
+If set then even streamed/non seekable streams will be reconnected on errors.
 @end table
 
 For more information see: @url{https://curl.se/libcurl/}.
diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index b237bc9319..5227b29fe3 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -123,6 +123,7 @@ struct CurlContext {
     int64_t         short_seek_size;
     int             max_retries;
     char           *reconnect_on_http_error;
+    int             reconnect_streamed;
 
     /* URL thread bookkeeping, not touched by loop thread */
     int64_t         logical_pos;    /* next byte url_read() will return, caller side */
@@ -184,7 +185,7 @@ static int http_error_is_recoverable(CurlContext *c, long http_status)
 {
     if (!c->reconnect_on_http_error || !http_status)
         return 0;
-    if (!c->seekable && c->request_start > 0)
+    if (!c->seekable && c->request_start > 0 && !c->reconnect_streamed)
         return 0;
 
     const char *status_group = NULL;
@@ -202,7 +203,7 @@ static int http_error_is_recoverable(CurlContext *c, long http_status)
 
 static int curl_error_is_recoverable(CurlContext *c, CURLcode code)
 {
-    if (!c->seekable && c->request_start > 0)
+    if (!c->seekable && c->request_start > 0 && !c->reconnect_streamed)
         return 0;
 
     switch (code) {
@@ -1309,6 +1310,7 @@ static const AVOption options[] = {
         { "3only",             "HTTP/3 only",                           0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_3ONLY },               0, 0, D, .unit = "http_version" },
     { "short_seek_size", "threshold to favor readahead over seek", OFFSET(short_seek_size), AV_OPT_TYPE_INT64, { .i64 = 0 }, 0, INT64_MAX, D },
     { "reconnect_on_http_error", "list of http status codes to reconnect on", OFFSET(reconnect_on_http_error), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, D },
+    { "reconnect_streamed", "auto reconnect streamed / non seekable streams", OFFSET(reconnect_streamed), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, D },
     { NULL }
 };
 
-- 
2.52.0


>From b742f8fac70d8117f1a82419fc3bfe2f3ae6cc8c Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:32:05 +0200
Subject: [PATCH 12/16] avformat/libcurl: add delay before subsequent retries

Note that unlike http.c, we do not add a specific reconnect_delay_max or
reconnect_delay_total_max option, because URLProtocol already has a common
-rw_timeout option that may just as well use to limit the total time we're
willing to wait for a transfer.

There is already no expectation that a general URL protocol request will
complete in any reasonable amount of time, and the -rw_timeout option is
the mechanism explicitly designed to limit this.

Note that we need to explicitly cache the next retry timestamp the first
time the retry() function is called, and continuously compare it against
the current timestamp; otherwise we would end up in an infinite loop if the
caller set AVIO_FLAG_NONBLOCK, with no way to observe the result of the
caller sleeping before retrying.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 50 +++++++++++++++++++++++++++++++++++++------
 1 file changed, 44 insertions(+), 6 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 5227b29fe3..ea91c87045 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -41,6 +41,7 @@
 #include "avformat.h"
 #include "http.h"
 #include "internal.h"
+#include "network.h"
 #include "url.h"
 #include "version.h"
 
@@ -51,6 +52,12 @@
  * callback. */
 #define CURL_WAIT_US 100000
 
+/* How many microseconds to wait before retrying a failed request; grows
+ * exponentially (2^) with each consecutive failure up to MAX_US. Note that the
+ * first retry is always immediate. */
+#define CURL_RETRY_BASE_US 1000000
+#define CURL_RETRY_MAX_US  60000000
+
 typedef struct CurlContext CurlContext;
 
 enum cmd_kind {
@@ -128,6 +135,7 @@ struct CurlContext {
     /* URL thread bookkeeping, not touched by loop thread */
     int64_t         logical_pos;    /* next byte url_read() will return, caller side */
     int             retry_count;    /* consecutive recoverable failures */
+    int64_t         retry_time;     /* timestamp of next retry */
 
     /* Producer bookkeeping, touched only by the loop thread. */
     int             active;          /* currently added to the multi */
@@ -1040,7 +1048,18 @@ static int wait_for_probe(CurlContext *c)
     return ret;
 }
 
-static int retry_request(URLContext *h)
+/* Scales by the recurrence relationship x := 2x + 1, i.e. 2^n - 1 */
+static int64_t retry_delay(CurlContext *c)
+{
+    if (c->retry_count >= 64)
+        return CURL_RETRY_MAX_US;
+    int64_t factor = (1LL << c->retry_count) - 1;
+    if (factor >= CURL_RETRY_MAX_US / CURL_RETRY_BASE_US)
+        return CURL_RETRY_MAX_US;
+    return factor * CURL_RETRY_BASE_US;
+}
+
+static int retry_request(URLContext *h, int nonblock)
 {
     CurlContext *c = h->priv_data;
 
@@ -1050,16 +1069,35 @@ static int retry_request(URLContext *h)
         return AVERROR(EIO);
     }
 
+    const int64_t now = av_gettime_relative();
+    if (!c->retry_time)
+        c->retry_time = now + retry_delay(c);
+
+    const int64_t sleep_us = c->retry_time - now;
+    if (sleep_us > 0 && nonblock)
+        return AVERROR(EAGAIN);
+    else if (h->rw_timeout && sleep_us >= h->rw_timeout)
+        return AVERROR(EIO);
+
     c->retry_count++;
-    av_log(h, AV_LOG_WARNING, "Retrying (#%d) from %"PRId64"\n",
-           c->retry_count, c->logical_pos);
+    c->retry_time = 0;
+    av_log(h, AV_LOG_WARNING, "Retrying (#%d) from %"PRId64" in %.3fs\n",
+           c->retry_count, c->logical_pos, sleep_us * 1e-6);
+
+    int ret = ff_network_sleep_interruptible(sleep_us, &h->interrupt_callback);
+    if (ret != AVERROR(ETIMEDOUT)) {
+        pthread_mutex_lock(&c->mutex);
+        c->aborted = 1;
+        pthread_mutex_unlock(&c->mutex);
+        return ret;
+    }
 
     /**
      * Use a synchronous request to ensure that the seek is registered, and
      * the reset of c->state is observable, before the next libcurl_read()
      * call, otherwise this might hit the exact same retry path a second time.
      */
-    int ret = curl_dispatch(c->loop, CMD_SEEK, c, c->logical_pos, 1);
+    ret = curl_dispatch(c->loop, CMD_SEEK, c, c->logical_pos, 1);
     if (ret < 0)
         return ret;
 
@@ -1132,7 +1170,7 @@ static int libcurl_open(URLContext *h, const char *url, int flags,
         ret = wait_for_probe(c);
         if (ret == AVERROR(EAGAIN)) {
             c->probed = 0;
-            ret = retry_request(h);
+            ret = retry_request(h, 0);
         } else if (ret < 0)
             goto fail;
     } while (ret == AVERROR(EAGAIN));
@@ -1173,7 +1211,7 @@ static int libcurl_read(URLContext *h, unsigned char *buf, int size)
         }
         if (c->status == AVERROR(EAGAIN)) {
             pthread_mutex_unlock(&c->mutex);
-            return retry_request(h);
+            return retry_request(h, nonblock);
         } else if (c->status) {
             ret = c->status;
             break;
-- 
2.52.0


>From 7ecd5109b687bca00a0adb8e3ab98fba0128f80e Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:38:55 +0200
Subject: [PATCH 13/16] avformat/libcurl: add -respect_retry_after option

Feature parity with http.c. This overrides the built-in retry delay if
set.

It's worth pointing out that c->retry_after does not require lock on read,
because it is not modified by the loop thread after the request has already
failed.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 doc/protocols.texi    |  5 +++++
 libavformat/libcurl.c | 16 +++++++++++++++-
 2 files changed, 20 insertions(+), 1 deletion(-)

diff --git a/doc/protocols.texi b/doc/protocols.texi
index 9ae0fbc936..33e8a103b9 100644
--- a/doc/protocols.texi
+++ b/doc/protocols.texi
@@ -1184,6 +1184,11 @@ include specific status codes (e.g. '503') or the strings '4xx' / '5xx'.
 
 @item reconnect_streamed
 If set then even streamed/non seekable streams will be reconnected on errors.
+
+@item respect_retry_after
+If enabled, and a Retry-After header is encountered, its requested reconnection
+delay will be honored, rather than using exponential backoff. Useful for 429 and
+503 errors. Default enabled.
 @end table
 
 For more information see: @url{https://curl.se/libcurl/}.
diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index ea91c87045..3cc1935921 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -131,6 +131,7 @@ struct CurlContext {
     int             max_retries;
     char           *reconnect_on_http_error;
     int             reconnect_streamed;
+    int             respect_retry_after;
 
     /* URL thread bookkeeping, not touched by loop thread */
     int64_t         logical_pos;    /* next byte url_read() will return, caller side */
@@ -152,11 +153,12 @@ struct CurlContext {
     int64_t         hdr_content_end;   /* inclusive end,   or -1 */
     int64_t         hdr_content_total; /* if known, or -1 */
 
-    /* Probe result. Set by the loop thread, read by url_open() once probed. */
+    /* Probe result. Set by the loop thread, read by URL thread once probed. */
     int             probed;
     int             stream_ok;
     int             seekable;
     int64_t         content_size;
+    int64_t         retry_after;
 
     /* Shared transfer state, guarded by mutex. */
     pthread_mutex_t mutex;
@@ -409,6 +411,10 @@ static size_t header_callback(char *ptr, size_t size, size_t nitems, void *userd
             else
                 c->status = ff_http_averror(status, AVERROR(EIO));
         }
+
+        curl_off_t retry_after = 0;
+        curl_easy_getinfo(c->easy, CURLINFO_RETRY_AFTER, &retry_after);
+        c->retry_after = retry_after;
     }
     c->probed = 1;
     pthread_cond_broadcast(&c->cond);
@@ -457,6 +463,7 @@ static void start_request(CurlContext *c)
     c->loop->num_requests++;
     c->request_received = 0;
     c->request_end = -1;
+    c->retry_after = 0;
     c->active = 1;
     CURLMcode res = curl_multi_add_handle(c->loop->multi, c->easy);
     if (res != CURLM_OK) {
@@ -1051,6 +1058,12 @@ static int wait_for_probe(CurlContext *c)
 /* Scales by the recurrence relationship x := 2x + 1, i.e. 2^n - 1 */
 static int64_t retry_delay(CurlContext *c)
 {
+    int64_t retry_after = c->retry_after;
+    if (c->respect_retry_after && retry_after) {
+        retry_after = FFMIN(retry_after, INT64_MAX / 1000000);
+        return retry_after * 1000000;
+    }
+
     if (c->retry_count >= 64)
         return CURL_RETRY_MAX_US;
     int64_t factor = (1LL << c->retry_count) - 1;
@@ -1349,6 +1362,7 @@ static const AVOption options[] = {
     { "short_seek_size", "threshold to favor readahead over seek", OFFSET(short_seek_size), AV_OPT_TYPE_INT64, { .i64 = 0 }, 0, INT64_MAX, D },
     { "reconnect_on_http_error", "list of http status codes to reconnect on", OFFSET(reconnect_on_http_error), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, D },
     { "reconnect_streamed", "auto reconnect streamed / non seekable streams", OFFSET(reconnect_streamed), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, D },
+    { "respect_retry_after", "respect the Retry-After header when retrying connections", OFFSET(respect_retry_after), AV_OPT_TYPE_BOOL, { .i64 = 1 }, 0, 1, D },
     { NULL }
 };
 
-- 
2.52.0


>From 9e131fa455deb24321e97f9dd7577e5a3639ed6f Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:44:20 +0200
Subject: [PATCH 14/16] avformat/libcurl: also continue sending requests on
 unknown size

Treat files with unknown size as having infinite size, at least until we
see a 416 Range Not Satisfiable.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 3cc1935921..e7a4daa90b 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -555,7 +555,9 @@ static void on_done(CurlContext *c, CURLcode code)
         int64_t file_end = c->content_size > 0 ? c->content_size - 1 : -1;
         if (c->end_off > 0)
             file_end = FFMIN(file_end, c->end_off - 1);
-        if (c->seekable && c->request_end >= 0 && c->request_end < file_end) {
+        if (c->seekable && c->request_end >= 0 &&
+            (c->request_end < file_end || file_end < 0))
+        {
             c->is_initial = 0;
             start_request(c);
             return;
-- 
2.52.0


>From 21b34bcd5d8db6d873e17fab890e7b17a47e6aea Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Thu, 9 Jul 2026 19:48:37 +0200
Subject: [PATCH 15/16] avformat/libcurl: don't tear down connection on HTTP
 error

This otherwise defeats the purpose of keep-alive / -multiple_requests,
especially when hitting a temporary error (429, 416, 503, etc).

The CURL_WRITEFUNC_ERROR is used to interrupt curl mid-transfer, which is
only really needed for c->aborted anyways.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavformat/libcurl.c | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index e7a4daa90b..1911152e52 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -245,12 +245,12 @@ static size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdat
 
     pthread_mutex_lock(&c->mutex);
 
-    if (c->aborted || !c->stream_ok) {
+    if (c->aborted) {
         pthread_mutex_unlock(&c->mutex);
         return CURL_WRITEFUNC_ERROR;
     }
 
-    if (c->seek_queued) {
+    if (c->seek_queued || !c->stream_ok) {
         pthread_mutex_unlock(&c->mutex);
         return bytes; /* discard */
     }
-- 
2.52.0


>From 8657b843658afc5015989e93b5811f404354bff4 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Tue, 21 Jul 2026 12:08:20 +0200
Subject: [PATCH 16/16] avformat/libcurl: retry 429 and 503 errors by default

These are explicitly considered temporary failures that should be retried,
and a conforming server would advertise a Retry-After header that we respect.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 doc/protocols.texi    | 1 +
 libavformat/libcurl.c | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/doc/protocols.texi b/doc/protocols.texi
index 33e8a103b9..18c392d725 100644
--- a/doc/protocols.texi
+++ b/doc/protocols.texi
@@ -1181,6 +1181,7 @@ is used for reading large video packets with small audio packets in between.
 @item reconnect_on_http_error
 A comma separated list of HTTP status codes to reconnect on. The list can
 include specific status codes (e.g. '503') or the strings '4xx' / '5xx'.
+Defaults to @code{429,503}.
 
 @item reconnect_streamed
 If set then even streamed/non seekable streams will be reconnected on errors.
diff --git a/libavformat/libcurl.c b/libavformat/libcurl.c
index 1911152e52..c763b49bb8 100644
--- a/libavformat/libcurl.c
+++ b/libavformat/libcurl.c
@@ -1362,7 +1362,7 @@ static const AVOption options[] = {
         { "3",                 "HTTP/3, fall back to earlier versions", 0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_3 },                   0, 0, D, .unit = "http_version" },
         { "3only",             "HTTP/3 only",                           0, AV_OPT_TYPE_CONST, { .i64 = CURL_HTTP_VERSION_3ONLY },               0, 0, D, .unit = "http_version" },
     { "short_seek_size", "threshold to favor readahead over seek", OFFSET(short_seek_size), AV_OPT_TYPE_INT64, { .i64 = 0 }, 0, INT64_MAX, D },
-    { "reconnect_on_http_error", "list of http status codes to reconnect on", OFFSET(reconnect_on_http_error), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, D },
+    { "reconnect_on_http_error", "list of http status codes to reconnect on", OFFSET(reconnect_on_http_error), AV_OPT_TYPE_STRING, { .str = "429,503" }, 0, 0, D },
     { "reconnect_streamed", "auto reconnect streamed / non seekable streams", OFFSET(reconnect_streamed), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, D },
     { "respect_retry_after", "respect the Retry-After header when retrying connections", OFFSET(respect_retry_after), AV_OPT_TYPE_BOOL, { .i64 = 1 }, 0, 1, D },
     { NULL }
-- 
2.52.0

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]