8000 HTTP Early Hints by arut · Pull Request #326 · nginx/nginx · GitHub
[go: up one dir, main page]

Skip to content

HTTP Early Hints #326

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 19, 2025
Merged

HTTP Early Hints #326

merged 2 commits into from
Jun 19, 2025

Conversation

arut
Copy link
Contributor
@arut arut commented Nov 17, 2024

A revamped version described here: #326 (comment)

Original text:

The change implements parsing Early Hints response from the proxied server and forwarding it to the client. Also, an Early Hints response could be sent to the client by the upstream module before choosing a proxied server. This happens if there are headers to send, which can be configured or added by third-party modules using the early hint filters.

A new parameter "early" is added to the "add_header" directive. The parameter instructs nginx to send this header as part of Early Hints response. The header is also sent in the main response. Also, a new directive "early_hints" is added, which accepts a predicate to enable Early Hints.

Example:

add_header X-Foo foo early;
early_hints $http2 $http3;
proxy_pass http://bar.example.com;

upd: added early_hints directive and renamed the parameter to early.

How to avoid issues for clients that don't support Early Hints (thanks @bavshin-f5 ).

map $http_sec_fetch_mode $early_hints {
    navigate $http2$http3;
}

early_hints $early_hints;

@arut arut mentioned this pull request Nov 17, 2024

*pos++ = 0;

pos = ngx_http_v2_write_name(pos, header[i].key.data,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that the following line should be "implemented" here:
#define NGX_HTTP_V2_LINK_INDEX 45

In the case of EarlyHints, the only header that is relevant is Link.
A similar issue exists for HTTP/3, where the header should utilize the static table for HPACK/QPACK encoding.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@patryk4815 thanks, this makes sense

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we do this, it should be done for all responses, not just early hints. And it will probably require direct referencing Link headers from the headers structure. Overall, a good optimization for the future.

@arut
Copy link
Contributor Author
arut commented Apr 17, 2025

Updated the patch.

Change: proxy_buffer_size now sets max cumulative size for all backend early hints
Bugfix: broken output when early hints are disabled, but backend sends them

For the record, client may receive multiple early hints from different backends in case of main header http or network error.

@pluknet pluknet self-requested a review May 1, 2025 14:12
@arut
Copy link
Contributor Author
arut commented May 2, 2025

Reworked this. Now it's two patches.

The first one adds early hints proxy. A new directive proxy_pass_early_hints enables this. Another new directive early_hints receives a predicate to enable early hints for this client (enabled by default for HTTP/2+). In any case early hints are disabled for HTTP/1 since 1xx responses are not supported in this version (100 Continue has the same restriction now).

 # early_hints $arg_client_supports_early_hints;
    
proxy_pass_early_hints on;
proxy_pass http://bar.example.com;

The second patch adds proxy_early_hint and friends to send early hints to client prior to connecting to upstream. There's no clear evidence this functionality is needed. So this patch is just a PoC mostly for testing. I'm planning to remove it from the series prior to merge.

proxy_early_hint X-Foo foo;
proxy_early_hint X-Bar bar;
proxy_pass http://bar.example.com;

@arut arut changed the title HTTP Early Hints PoC HTTP Early Hints May 2, 2025
@bukka
Copy link
bukka commented May 2, 2025

@arut have you had any thoughts how could this be supported for FastCGI proxy? I'm php-fpm maintainer and we would be open to do some tweaks in our implementation for that.

@arut
Copy link
Contributor Author
arut commented May 3, 2025

@arut have you had any thoughts how could this be supported for FastCGI proxy? I'm php-fpm maintainer and we would be open to do some tweaks in our implementation for that.

Not yet, but we'll take a look at this.

Copy link
Contributor
@pluknet pluknet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the record, the conclusion from our elsewhere conversation is to proceed with the 1st commit only, while keeping in mind the 2nd commit as a possible further improvement.

Some generic comments on the 1st commit log.

Upstream: early hints proxy.

Such summary looks somewhat vague, what about:

Added support for early hints in HTTP responses.

The change implements parsing early hints response from the proxied server.

s/parsing/processing/? as it is not just about parsing

But then it would make the sentence superfluous to the next one.
So you might consider to remove this sentence for brevity.

By default, early hints are enabled for HTTP/2 and
HTTP/3 and disabled for earlier HTTP versions due to protocol

The change actually disables it for the HTTP/1.1 protocol by default, and unconditionally disables for older protocols, such as HTTP/1.0. This may need a further clarification in this regard.
You may also want to mention that this is done to meet RFC 8297 security considerations, optionally replacing the "non-early-hints-aware" vague part.

# early_hints $arg_client_supports_early_hints;

While technically correct, the example could be somewhat more usable.

proxy_pass http://bar.example.com;

just example.com should be as good

@arut
Copy link
Contributor Author
arut commented Jun 17, 2025

For the record, the conclusion from our elsewhere conversation is to proceed with the 1st commit only, while keeping in mind the 2nd commit as a possible further improvement.

Some generic comments on the 1st commit log.

Upstream: early hints proxy.

Such summary looks somewhat vague, what about:

Added support for early hints in HTTP responses.

Its sounds like we are talking about client-side here., while it's all about the proxy side.

The change implements parsing early hints response from the proxied server.

s/parsing/processing/? as it is not just about parsing

ok

But then it would make the sentence superfluous to the next one. So you might consider to remove this sentence for brevity.

By default, early hints are enabled for HTTP/2 and
HTTP/3 and disabled for earlier HTTP versions due to protocol

The change actually disables it for the HTTP/1.1 protocol by default, and unconditionally disables for older protocols, such as HTTP/1.0. This may need a further clarification in this regard. You may also want to mention that this is done to meet RFC 8297 security considerations, optionally replacing the "non-early-hints-aware" vague part.

Rewritten this part and added RFC quotes.

# early_hints $arg_client_supports_early_hints;

While technically correct, the example could be somewhat more usable.

OK, added an example implementing this recommendation.

proxy_pass http://bar.example.com;

just example.com should be as good

ok

@arut
Copy link
Contributor Author
arut commented Jun 17, 2025

Also, implemented these changes:

  • fixed the case when the main header immediately follows early hints in the same buffer
  • now resetting some request fields (disable_not_modified, allow_ranges, single_range) after sending early hints
  • added debug logging

Copy link
Contributor
@pluknet pluknet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some 1st round comments

}
}

goto read_header;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This results in the extra recv() call in a typical case of early hints sent separately.
Any reasons to do so?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's what happens normally after u->process_header() returns NGX_AGAIN. I don't think it makes sense to optimize this only for early hints.

static ngx_int_t ngx_http_early_hints_filter(ngx_http_request_t *r);
static ngx_int_t ngx_http_header_filter(ngx_http_request_t *r);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: these two filters are declared and defined (and used) in a different order.

@@ -50,6 +51,9 @@ static u_char ngx_http_server_string[] = "Server: nginx" CRLF;
static u_char ngx_http_server_full_string[] = "Server: " NGINX_VER CRLF;
static u_char ngx_http_server_build_string[] = "Server: " NGINX_VER_BUILD CRLF;

static ngx_str_t ngx_http_early_hints_status_line
= ngx_string("103 Early Hints");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: lines with assignments are wrapped after the assignment sign:

$ grep -r '^static ngx_str_t .* =$' src
src/http/modules/ngx_http_log_module.c:static ngx_str_t  ngx_http_combined_fmt =
src/http/modules/ngx_http_secure_link_module.c:static ngx_str_t  ngx_http_secure_link_expires_name =
$ grep -r '^static u_char .* =$' src
src/mail/ngx_mail_smtp_handler.c:static u_char  smtp_invalid_pipelining[] =
src/http/ngx_http_special_response.c:static u_char ngx_http_error_full_tail[] =
src/http/ngx_http_special_response.c:static u_char ngx_http_error_build_tail[] =
src/http/ngx_http_special_response.c:static u_char ngx_http_error_tail[] =
src/http/ngx_http_special_response.c:static u_char ngx_http_msie_padding[] =
src/http/ngx_http_special_response.c:static u_char ngx_http_msie_refresh_head[] =
src/http/ngx_http_special_response.c:static u_char ngx_http_msie_refresh_tail[] =
src/http/modules/ngx_http_grpc_module.c:static u_char  ngx_http_grpc_connection_start[] =

but:

$ grep -E -r '^ {4}= ' src
$ grep -E -r '^ *= ' src
$ 

Comment on lines 685 to 691
/* "HTTP/1.x" and status line */
b->last = ngx_cpymem(b->last, "HTTP/1.1 ", sizeof("HTTP/1.x ") - 1);

b->last = ngx_copy(b->last, ngx_http_early_hints_status_line.data,
ngx_http_early_hints_status_line.len);

*b->last++ = CR; *b->last++ = LF;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes a copy (in several passes) of constant objects known in advance.
Instead, they could be references.

PoC how this could be improved to eliminate copies:

diff --git a/src/http/ngx_http_header_filter_module.c b/src/http/ngx_http_header_filter_module.c
index d1425c385..32a66859e 100644
--- a/src/http/ngx_http_header_filter_module.c
+++ b/src/http/ngx_http_header_filter_module.c
@@ -51,8 +51,8 @@ static u_char ngx_http_server_string[] = "Server: nginx" CRLF;
 static u_char ngx_http_server_full_string[] = "Server: " NGINX_VER CRLF;
 static u_char ngx_http_server_build_string[] = "Server: " NGINX_VER_BUILD CRLF;
 
-static ngx_str_t ngx_http_early_hints_status_line
-    = ngx_string("103 Early Hints");
+static u_char ngx_http_early_hints_string[] =
+    "HTTP/1.1 103 Early Hints" CRLF;
 
 
 static ngx_str_t ngx_http_status_lines[] = {
@@ -635,10 +635,12 @@ ngx_http_early_hints_filter(ngx_http_request_t *r)
     size_t            len;
     ngx_buf_t        *b;
     ngx_uint_t        i;
-    ngx_chain_t       out;
+    ngx_chain_t       out, hl;
     ngx_list_part_t  *part;
     ngx_table_elt_t  *header;
 
+    static ngx_buf_t  hb;
+
     if (r->http_version < NGX_HTTP_VERSION_11) {
         return NGX_OK;
     }
@@ -672,24 +674,13 @@ ngx_http_early_hints_filter(ngx_http_request_t *r)
         return NGX_OK;
     }
 
-    len += sizeof("HTTP/1.x ") - 1
-           + ngx_http_early_hints_status_line.len + sizeof(CRLF) - 1
-           /* the end of the early hints */
-           + sizeof(CRLF) - 1;
+    len += sizeof(CRLF) - 1;
 
     b = ngx_create_temp_buf(r->pool, len);
     if (b == NULL) {
         return NGX_ERROR;
     }
 
-    /* "HTTP/1.x" and status line */
-    b->last = ngx_cpymem(b->last, "HTTP/1.1 ", sizeof("HTTP/1.x ") - 1);
-
-    b->last = ngx_copy(b->last, ngx_http_early_hints_status_line.data,
-                       ngx_http_early_hints_status_line.len);
-
-    *b->last++ = CR; *b->last++ = LF;
-
     part = &r->headers_out.headers.part;
     header = part->elts;
 
@@ -716,20 +707,33 @@ ngx_http_early_hints_filter(ngx_http_request_t *r)
         *b->last++ = CR; *b->last++ = LF;
     }
 
-    ngx_log_debug2(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
-                   "%*s", (size_t) (b->last - b->pos), b->pos);
+    if (hb.temporary == 0) {
+        hb.start = ngx_http_early_hints_string;
+        hb.last = hb.start + sizeof(ngx_http_early_hints_string) - 1;
+        hb.end = hb.last;
+        hb.temporary = 1;
+    }
+
+    hb.pos = hb.start;
+
+    hl.buf = &hb;
+    hl.next = &out;
+
+    ngx_log_debug4(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "%*s%*s",
+                   (size_t) (hb.last - hb.pos), hb.pos,
+                   (size_t) (b->last - b->pos), b->pos);
 
     /* the end of HTTP early hints */
     *b->last++ = CR; *b->last++ = LF;
 
-    r->header_size = b->last - b->pos;
+    r->header_size = (hb.last - hb.pos) + (b->last - b->pos);
 
     b->flush = 1;
 
     out.buf = b;
     out.next = NULL;
 
-    return ngx_http_write_filter(r, &out);
+    return ngx_http_write_filter(r, &hl);
 }
 
 

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like too much code for saving 26 bytes (of which 16 bytes are used by ngx_chain_t anyway). Also, an additional chain link leads to more code executed down the filter chain. I agree that the entire 103 status line can be a single string, but an additional buffer complicates things too much.

ngx_int_t rc;
ngx_http_core_loc_conf_t *clcf;

if (r != r->main || r->header_sent) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

r->header_sent should not happen here, similar to ngx_http_send_header()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed separately, I have added the same check for r->header_sent as in ngx_http_send_header().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, added a check for r->post_action similar to ngx_http_send_header() and moved r != r->main check to filter handlers, similar to the main filter chain.

clcf = ngx_http_get_module_loc_conf(r, ngx_http_core_module);

if (clcf->early_hints == NULL) {
/* by default enabled for http/2+ */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • the proper spelling is HTTP/2
  • anyway, the comment doesn't seem to be useful

@@ -3636,6 +3677,7 @@ ngx_http_core_create_loc_conf(ngx_conf_t *cf)
clcf->recursive_error_pages = NGX_CONF_UNSET;
clcf->chunked_transfer_encoding = NGX_CONF_UNSET;
clcf->etag = NGX_CONF_UNSET;
clcf->early_hints = NGX_CONF_UNSET_PTR;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsorted here and below, in the merge phase

at least I'd put it consistently before xor after server_tokens

(ngx_http_core_loc_conf_s seems to be perfectly unsorted, doesn't worth wasting time on this one)

Comment on lines 678 to 679
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
"http2 early hints filter");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's the only early filter with entry logging

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP/2 header filter is also the only header filter like that. Anyway, removed the logging for better compliance with other header filters.

@arut
Copy link
Contributor Author
arut commented Jun 17, 2025

After an internal discussion, avoided calling ngx_http_upstream_process_headers() for early hints, since these are not real headers. Now it is similar to how trailers works.

Copy link
Contributor
@pluknet pluknet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 1st commit makes early hints integration in the grpc module pretty easy:

diff --git a/src/http/modules/ngx_http_grpc_module.c b/src/http/modules/ngx_http_grpc_module.c
index 80046d6a4..bc3cc1351 100644
--- a/src/http/modules/ngx_http_grpc_module.c
+++ b/src/http/modules/ngx_http_grpc_module.c
@@ -1869,7 +1869,7 @@ ngx_http_grpc_process_header(ngx_http_request_t *r)
                         return NGX_HTTP_UPSTREAM_INVALID_HEADER;
                     }
 
-                    if (status < NGX_HTTP_OK) {
+                    if (status < NGX_HTTP_OK && status != NGX_HTTP_EARLY_HINTS) {
                         ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
                                       "upstream sent unexpected :status \"%V\"",
                                       status_line);
@@ -4413,6 +4413,7 @@ ngx_http_grpc_create_loc_conf(ngx_conf_t *cf)
     conf->upstream.pass_request_body = 1;
     conf->upstream.force_ranges = 0;
     conf->upstream.pass_trailers = 1;
+    conf->upstream.pass_early_hints = 1;
     conf->upstream.preserve_output = 1;
 
     conf->headers_source = NGX_CONF_UNSET_PTR;

It is made unconditional following pass_trailers.

Again, this raises a question whether we actually need the proxy_pass_early_hints directive. There doesn't seem to be any cautions to make receiving early hints upstream-side optional, which also meets HTTP semantics (RFC 9110):

A client MUST be able to parse one or more 1xx responses received
prior to a final response, even if the client does not expect one.
 A user agent MAY ignore unexpected 1xx responses.

arut added 2 commits June 18, 2025 19:48
The change implements processing upstream early hints response in
ngx_http_proxy_module and ngx_http_grpc_module.  A new directive
"early_hints" enables sending early hints to the client.  By default,
sending early hints is disabled.

Example:

    map $http_sec_fetch_mode $early_hints {
        navigate $http2$http3;
    }

    early_hints $early_hints;

    proxy_pass http://example.com;
@arut arut merged commit 662c1dd into nginx:master Jun 19, 2025
1 check passed
@arut arut deleted the early-hints branch June 19, 2025 06:20
@L1H0n9Jun
Copy link

@arut It seems that add_header X-Foo foo early; and proxy_early_hint X-Foo foo; in discussion are both deprecated. How should early headers be formally constructed now? Sorry, I don't have a deep understanding of the implementation details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants
0