CURL HTTP/3 Not Working? Troubleshooting Libcurl
Introduction to the Problem: Why Isn't CURL_HTTP_VERSION_3ONLY Working?
Hey guys! I've been wrestling with a peculiar issue in libcurl, specifically the CURLOPT_HTTP_VERSION option. My goal was to force curl to use HTTP/3 exclusively by setting it to CURL_HTTP_VERSION_3ONLY. However, it seems like something isn't quite right. Despite my best efforts, curl keeps reusing existing connections instead of establishing new HTTP/3 connections as I expected. This is a real head-scratcher, and I'm eager to understand what's going on. This is important because it impacts how we can test and leverage the latest web protocols, and I want to share my findings with you all. So, let's dive into the details, explore the code, and figure out what might be causing this behavior. It's a journey into the heart of network protocols and how libcurl handles them. So, buckle up!
I used nghttp2, nghttp3, and libcurl to test HTTP/2 and HTTP/3 protocols. The tests revealed that setting CURLOPT_HTTP_VERSION to CURL_HTTP_VERSION_3ONLY did not work, reusing existing connections. This is my demo program; through debug callbacks, you can see in the terminal that CURL_HTTP_VERSION_3ONLY indeed did not take effect.
The Code Breakdown: A Deep Dive
To demonstrate this problem, I've created a simple C program. This program is designed to make both HTTP/2 and HTTP/3 requests to zoom.us. The code sets up two curl handles. The first one explicitly requests an HTTP/2 connection. The second attempts to enforce an HTTP/3 connection using CURL_HTTP_VERSION_3ONLY. I've included debug callbacks to print verbose information, allowing us to see exactly what's happening under the hood. The output from this program is especially telling. It clearly shows curl reusing an existing connection for the HTTP/3 request, which is not what we want.
#include <iostream>
#include "curl/curl.h"
// Callback function to handle response data
size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdata) {
size_t total_size = size * nmemb;
std::cout.write(ptr, total_size);
return total_size;
}
// Debug callback function
void debug_cb(CURL *handle,
curl_infotype type,
char *data,
size_t size,
void *clientp) {
(void)handle;
(void)clientp;
const char *text = "";
switch (type) {
case CURLINFO_TEXT:
text = "Info: ";
break;
case CURLINFO_HEADER_OUT:
text = "=> Send header: ";
break;
case CURLINFO_HEADER_IN:
text = "<= Recv header: ";
break;
default:
return;
}
std::cout << text << std::string(data, size);
}
int main() {
curl_global_init(CURL_GLOBAL_DEFAULT);
CURLM *multi_handle = curl_multi_init();
// Configure HTTP/2 connection to zoom.us
std::cout << "=== Starting HTTP/2 GET request to zoom.us ===" << std::endl;
CURL *h2_easy_handle = curl_easy_init();
curl_easy_setopt(h2_easy_handle, CURLOPT_URL, "https://zoom.us");
curl_easy_setopt(h2_easy_handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
curl_easy_setopt(h2_easy_handle, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(h2_easy_handle, CURLOPT_VERBOSE, 1L);
curl_easy_setopt(h2_easy_handle, CURLOPT_DEBUGFUNCTION, debug_cb);
curl_easy_setopt(h2_easy_handle, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(h2_easy_handle, CURLOPT_SSL_VERIFYHOST, 2L);
curl_multi_add_handle(multi_handle, h2_easy_handle);
int running = 0;
curl_multi_perform(multi_handle, &running);
while (running) {
int numfds = 0;
curl_multi_wait(multi_handle, NULL, 0, 1000, &numfds);
curl_multi_perform(multi_handle, &running);
}
// Check HTTP/2 request result
CURLcode h2_result;
curl_easy_getinfo(h2_easy_handle, CURLINFO_RESPONSE_CODE, &h2_result);
std::cout << "\n=== HTTP/2 request completed ===" << std::endl;
curl_multi_remove_handle(multi_handle, h2_easy_handle);
curl_easy_cleanup(h2_easy_handle);
// Configure HTTP/3 connection to zoom.us
std::cout << "\n=== Starting HTTP/3 GET request to zoom.us ===" << std::endl;
CURL *h3_easy_handle = curl_easy_init();
curl_easy_setopt(h3_easy_handle, CURLOPT_URL, "https://zoom.us");
curl_easy_setopt(h3_easy_handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_3ONLY);
curl_easy_setopt(h3_easy_handle, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(h3_easy_handle, CURLOPT_VERBOSE, 1L);
curl_easy_setopt(h3_easy_handle, CURLOPT_DEBUGFUNCTION, debug_cb);
curl_easy_setopt(h3_easy_handle, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(h3_easy_handle, CURLOPT_SSL_VERIFYHOST, 2L);
curl_multi_add_handle(multi_handle, h3_easy_handle);
running = 1;
while (running) {
int numfds = 0;
curl_multi_wait(multi_handle, NULL, 0, 1000, &numfds);
curl_multi_perform(multi_handle, &running);
}
// Check HTTP/3 request result
CURLcode h3_result;
curl_easy_getinfo(h3_easy_handle, CURLINFO_RESPONSE_CODE, &h3_result);
std::cout << "\n=== HTTP/3 request completed ===" << std::endl;
curl_multi_remove_handle(multi_handle, h3_easy_handle);
curl_easy_cleanup(h3_easy_handle);
curl_multi_cleanup(multi_handle);
curl_global_cleanup();
return 0;
}
Analyzing the Output: What the Debug Logs Show
The output from the program is pretty telling. It first makes an HTTP/2 request and then attempts to make an HTTP/3 request. You'll notice that for the HTTP/3 request, curl reuses the existing connection established for the HTTP/2 request. The debug output confirms this. This reuse is not what we want when we specifically ask for HTTP/3 only. It means that CURL_HTTP_VERSION_3ONLY isn't having the intended effect. Let's dig deeper into the code to understand why this is happening. The debug output is the smoking gun here, and it's essential to understanding the root of the problem.
=== Starting HTTP/2 GET request to zoom.us ===
Info: Host zoom.us:443 was resolved.
Info: IPv6: (none)
Info: IPv4: 170.114.52.2
Info: Trying 170.114.52.2:443...
Info: ALPN: curl offers h2,http/1.1
Info: TLSv1.3 (OUT), TLS handshake, Client hello (1):
Info: CAfile: /etc/ssl/cert.pem
Info: CApath: none
Info: TLSv1.3 (IN), TLS handshake, Server hello (2):
Info: TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
Info: TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
Info: TLSv1.3 (IN), TLS handshake, Unknown (25):
Info: TLSv1.3 (IN), TLS handshake, CERT verify (15):
Info: TLSv1.3 (IN), TLS handshake, Finished (20):
Info: TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
Info: TLSv1.3 (OUT), TLS handshake, Finished (20):
Info: SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / RSASSA-PSS
Info: ALPN: server accepted h2
Info: Server certificate:
Info: subject: C=US; ST=California; L=San Jose; O=Zoom Video Communications, Inc.; CN=*.zoom.us
Info: start date: Feb 8 00:00:00 2025 GMT
Info: expire date: Feb 11 23:59:59 2026 GMT
Info: subjectAltName: host "zoom.us" matched cert's "zoom.us"
Info: issuer: C=US; O=DigiCert Inc; CN=DigiCert Global G2 TLS RSA SHA256 2020 CA1
Info: SSL certificate verify ok.
Info: Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
Info: Certificate level 1: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
Info: Certificate level 2: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
Info: Connected to zoom.us (170.114.52.2) port 443
Info: using HTTP/2
Info: [HTTP/2] [1] OPENED stream for https://zoom.us/
Info: [HTTP/2] [1] [:method: GET]
Info: [HTTP/2] [1] [:scheme: https]
Info: [HTTP/2] [1] [:authority: zoom.us]
Info: [HTTP/2] [1] [:path: /]
Info: [HTTP/2] [1] [accept: */*]
=> Send header: GET / HTTP/2
Host: zoom.us
Accept: */*
Info: Request completely sent off
Info: TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
Info: TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
<= Recv header: HTTP/2 301
<= Recv header: date: Mon, 17 Nov 2025 08:28:28 GMT
<= Recv header: content-type: text/html
<= Recv header: content-length: 167
<= Recv header: location: https://www.zoom.com
<= Recv header: cache-control: max-age=3600
<= Recv header: expires: Mon, 17 Nov 2025 09:28:28 GMT
<= Recv header: report-to: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=fZZlxUixozR%2Fet1qnh0JIrZj74Vtqeo8c%2BbUxxXmMux%2Bthy%2BZSrBPFKP8eurFxP126wDrywYjliEz1BZrK6zf%2BU%2FinqGV%2FSsDQB7WaxOSnvThnNmxJOQ%2Fi8%3D"}],"group":"cf-nel","max_age":604800}
<= Recv header: nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
<= Recv header: server: cloudflare
<= Recv header: cf-ray: 99fddad5ae803d57-SJC
<= Recv header: alt-svc: h3=":443"; ma=86400
<= Recv header:
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
Info: Connection #0 to host zoom.us left intact
=== HTTP/2 request completed ===
=== Starting HTTP/3 GET request to zoom.us ===
Info: Re-using existing connection with host zoom.us
Info: [HTTP/2] [3] OPENED stream for https://zoom.us/
Info: [HTTP/2] [3] [:method: GET]
Info: [HTTP/2] [3] [:scheme: https]
Info: [HTTP/2] [3] [:authority: zoom.us]
Info: [HTTP/2] [3] [:path: /]
Info: [HTTP/2] [3] [accept: */*]
=> Send header: GET / HTTP/2
Host: zoom.us
Accept: */*
Info: Request completely sent off
<= Recv header: HTTP/2 301
<= Recv header: date: Mon, 17 Nov 2025 08:28:28 GMT
<= Recv header: content-type: text/html
<= Recv header: content-length: 167
<= Recv header: location: https://www.zoom.com
<= Recv header: cache-control: max-age=3600
<= Recv header: expires: Mon, 17 Nov 2025 09:28:28 GMT
<= Recv header: report-to: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=NuIbpiLenMTnmCmISK0yKhQaaGQRGsVPxHS94%2Blqboz12RxR2AliREpALV1Ku9F6gnvJsyELzpgOKVebTE4lxdPeSYZuOYqCFfOMyY2qIF8Cu33isqua58E%3D"}],"group":"cf-nel","max_age":604800}
<= Recv header: nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
<= Recv header: server: cloudflare
<= Recv header: cf-ray: 99fddad6f9be3d57-SJC
<= Recv header: alt-svc: h3=":443"; ma=86400
<= Recv header:
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
Info: Connection #0 to host zoom.us left intact
=== HTTP/3 request completed ===
Deep Dive into the Code: Where the Problem Lies
Let's cut to the chase and pinpoint the likely culprit: a specific section of the url.c file within libcurl. This is where the decision-making process for connection reuse happens. The code snippet below is the area that controls whether an existing connection can be reused or if a new one needs to be established. It checks if the requested HTTP version matches the existing connection's version, and if it doesn't, it decides whether to reuse the connection. The logic within this section seems to be the key to understanding why CURL_HTTP_VERSION_3ONLY isn't behaving as expected.
if((needle->handler->protocol & PROTO_FAMILY_HTTP) &&
(data->state.httpwant != CURL_HTTP_VERSION_2TLS)) {
unsigned char httpversion = Curl_conn_http_version(data);
if((httpversion >= 20) &&
(data->state.httpwant < CURL_HTTP_VERSION_2_0)) {
DEBUGF(infof(data, "nor reusing conn #%" CURL_FORMAT_CURL_OFF_T
" with httpversion=%d, we want a version less than h2",
conn->connection_id, httpversion));
}
if((httpversion >= 30) &&
(data->state.httpwant < CURL_HTTP_VERSION_3)) {
DEBUGF(infof(data, "nor reusing conn #%" CURL_FORMAT_CURL_OFF_T
" with httpversion=%d, we want a version less than h3",
conn->connection_id, httpversion));
return FALSE;
}
}
In this code, the conditions determine whether an existing connection can be reused. httpversion represents the HTTP version of the existing connection. data->state.httpwant represents the desired HTTP version. The crucial part is the second if statement: If the existing connection is HTTP/3 (httpversion >= 30) and the desired version is less than HTTP/3 (data->state.httpwant < CURL_HTTP_VERSION_3), the function should not reuse this connection and return FALSE.
The Heart of the Matter: Why Isn't It Working?
So, here's the million-dollar question: Why isn't this code behaving as expected? The issue likely stems from how the values are interpreted and how the connection is set up in the first place. When you set CURLOPT_HTTP_VERSION to CURL_HTTP_VERSION_3ONLY, you're telling curl, "I only want HTTP/3." However, it appears that if curl initially connects using HTTP/2 (or another version) and then you try to switch to CURL_HTTP_VERSION_3ONLY, it might not be correctly establishing a brand-new connection as it should. The problem might be the existing connection is not closed/invalidated correctly when setting CURLOPT_HTTP_VERSION to CURL_HTTP_VERSION_3ONLY after an HTTP/2 connection has already been established.
Essentially, the logic might be preventing the correct handling of the CURL_HTTP_VERSION_3ONLY option when a previous connection exists. It appears that the return FALSE statement is not being triggered as intended when a newer HTTP version (like HTTP/3) is desired. This could be due to a misunderstanding of how the HTTP version is being negotiated or set during the connection process.
Troubleshooting Steps and Potential Solutions
So, how do we fix this, guys? Let's brainstorm some potential solutions. Since the problem seems to be in how libcurl handles connection reuse, here are a few things we could try. First, ensure your libcurl version is up-to-date. There could be a bug that's been patched in a newer version. Second, you could try setting a specific CURLOPT_CONNECT_TO option. This option can force curl to make a new connection. This might help to override the default connection reuse behavior. Finally, you might want to consider explicitly closing existing connections. Before making the HTTP/3 request, you can use curl_easy_cleanup() to close the previous HTTP/2 connection. This might force curl to establish a new connection when you set CURLOPT_HTTP_VERSION to CURL_HTTP_VERSION_3ONLY.
Further Investigation: What to Do Next
To get to the bottom of this, we need to gather more information. First, we must confirm the libcurl version. Then, let's explore some other tests: Try using CURLOPT_CONNECT_TO to force a new connection. If that works, it gives us a workaround. After that, we could examine the server's HTTP/3 support: Does the server actually support HTTP/3? A server that doesn't support HTTP/3 would make the test fail, but it would be a server-side problem. We could also review the curl source code. While the code snippet gives us a clue, a more thorough look might reveal the true cause of the issue. Finally, consider reporting the bug: If you find a bug in the code, it's a good idea to report it to the libcurl maintainers, so that they can fix it. Remember, these steps are crucial to solve the issue, and hopefully, this will fix the problem.
Conclusion: Wrapping Things Up
So, there you have it, guys. We've explored why CURL_HTTP_VERSION_3ONLY might not be working as expected in libcurl. We looked at the code, analyzed the debug output, and discussed some potential solutions. This is an ongoing investigation, and I will be sure to keep you updated on any progress. Remember, the world of network protocols is complex, and sometimes, things don't go as planned. However, with a bit of code sleuthing, we can usually find a solution. Keep experimenting, keep learning, and keep asking questions. Until next time, happy coding!