Skip to content
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

[HttpFoundation] Add support for the 103 status code (Early Hints) and other 1XX statuses #48128

Open
wants to merge 12 commits into
base: 6.3
Choose a base branch
from

Conversation

dunglas
Copy link
Member

@dunglas dunglas commented Nov 6, 2022

Q A
Branch? 6.3
Bug fix? no
New feature? yes
Deprecations? yes
Tickets n/a
License MIT
Doc PR todo

This patch adds support for sending informational responses, including Early Hints responses if supported by the SAPI. It also allows sending other informational status codes such as 102 Processing.

According to Shopify and Cloudflare, using Early Hints, the performance improvement to the Largest Contentful Paint can go from several hundred milliseconds, and up to a second faster.

Usage:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\WebLink\Link;

class HomepageController extends AbstractController
{
    #[Route("/", name: "homepage")]
    public function index(): Response
    {
        $response = $this->sendEarlyHints([
            (new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'),
            (new Link(href: '/script.js'))->withAttribute('as', 'script'),
        ]);

        // Do something slow...

        return $this->render('homepage/index.html.twig', response: $response);
    }
}

With this patch, HttpFoundation will leverage the headers_send() function provided by FrankenPHP. FrankenPHP is currently the only SAPI supporting Early Hints, but other SAPI such as mod_apache will probably implement this function at some point: php/php-src#7025 (comment)

The low-level API is similar to the one provided by Go: golang/go#42597
The high-level API helper in AbstractController is similar to Node's one: nodejs/node#44180

@carsonbot

This comment was marked as outdated.

@dunglas dunglas force-pushed the feat/103-status-code branch 2 times, most recently from aa3684a to 85bb5ca Compare Nov 6, 2022
@welcoMattic welcoMattic modified the milestones: 6.2, 6.3 Nov 7, 2022
@carsonbot
Copy link

carsonbot commented Nov 7, 2022

Hey!

I think @pascalwoerde has recently worked with this code. Maybe they can help review this?

Cheers!

Carsonbot

@chalasr
Copy link
Member

chalasr commented Nov 8, 2022

As many Symfony devs don't know about the sendHeaders() method and concept, I think it would make sense to expose a more high-level API for this, e.g Response::sendInformationalResponse(STATUS) or even Response::sendEarlyHints(Link[] $links) or similar. WDYT?

Also, is it relevant to send preload link headers with both the informational and main response? (Yes, it is)

@derrabus
Copy link
Member

derrabus commented Nov 8, 2022

Is that deprecation necessary? Calling sendHeaders() without any arguments should remain a valid call imho.

@dunglas
Copy link
Member Author

dunglas commented Nov 12, 2022

@chalasr What would be the benefit of doing that? My proposal is very similar to how it's implemented in Go for instance. IMHO the current API is good enough and there is no need to make it more complex.

@derrabus the plan is to force passing null in Symfony 7 to be able to make this attribute optional again in Symfony 8. Is there another way to add an optional parameter without introducing a breaking change?

@derrabus
Copy link
Member

derrabus commented Nov 13, 2022

@derrabus the plan is to force passing null in Symfony 7 to be able to make this attribute optional again in Symfony 8.

And why don't we make it optional right from the start?

@chalasr
Copy link
Member

chalasr commented Nov 13, 2022

@chalasr What would be the benefit of doing that? My proposal is very similar to how it's implemented in Go for instance. IMHO the current API is good enough and there is no need to make it more complex.

It's not obvious that sendHeaders() is about sending a response, hence I think the proposed API is more complex. I think we'd better have something crystal clear than being consistent with Golang's low-level api IMHO

@dunglas
Copy link
Member Author

dunglas commented Nov 13, 2022

Informational responses are special: they can only be composed of headers, by the spec:

A 1xx response is terminated by the end of the header section; it cannot contain content or trailers.

https://httpwg.org/specs/rfc9110.html#status.1xx

So basically, sending 1XX responses mean sending headers ahead of time. Adding a new method would just add more confusion IMHO.

@chalasr
Copy link
Member

chalasr commented Nov 13, 2022

So basically basically, sending 1XX responses mean sending headers ahead of time

Yes, that's something I personally know but:

  • use cases for informational responses other than 103 are rare in the web area, I consider the concept to be quite new for Symfony at least.
  • nobody calls sendHeaders() currently
  • exposing this new use case via a status parameter in sendHeaders() does not feel future-proof. A dedicated API (maybe even experimental as this is not widely supported yet?) would provide us with more flexibility.

But maybe it's just me 🤷 hope others opinions will come.

Is there another way to add an optional parameter without introducing a breaking change?

A virtual parameter (@param) should be enough: no added noise for callers, only a deprecation notice for eventual children (triggered by DebugClassLoader)

@dunglas
Copy link
Member Author

dunglas commented Nov 14, 2022

@chalasr I hope that I've found an acceptable compromise:

  1. the low-level API (in HttpFoundation) stays as-is, and still allows to do advancing things such as sending WebDav's 102 Processing status code
  2. I added a higher-level, object-oriented helper in AbstractController dedicated to Early Hints:
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\WebLink\Link;

class HomepageController extends AbstractController
{
    #[Route("/", name: "homepage")]
    public function index(Request $request): Response
    {
        $response = $this->sendEarlyHints([
            (new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'),
            (new Link(href: '/script.js'))->withAttribute('as', 'script'),
        ]);

        // Do something slow...

        return $this->render('homepage/index.html.twig', response: $response);
    }
}

So we have the best of both worlds. WDYT?

@chalasr
Copy link
Member

chalasr commented Nov 14, 2022

Looks great! Thanks

UPGRADE-6.3.md Outdated Show resolved Hide resolved
src/Symfony/Component/HttpFoundation/CHANGELOG.md Outdated Show resolved Hide resolved
src/Symfony/Component/HttpFoundation/Response.php Outdated Show resolved Hide resolved
dunglas and others added 3 commits Nov 14, 2022
Co-authored-by: Christophe Coevoet <stof@notk.org>
Co-authored-by: Robin Chalas <chalasr@users.noreply.github.com>
chalasr
chalasr approved these changes Dec 3, 2022
Copy link
Member

@chalasr chalasr left a comment

Although AbstractController is not first-class citizen anymore, it's good for discovery and we could consider moving this method to the component later if needed.

@derrabus
Copy link
Member

derrabus commented Dec 9, 2022

$response = $this->sendEarlyHints([
    (new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'),
    (new Link(href: '/script.js'))->withAttribute('as', 'script'),
]);

This sends the headers immediately to the client, right? How can I test a controller that makes use of that feature?

@dunglas
Copy link
Member Author

dunglas commented Dec 9, 2022

Indeed. You can test using FrakenPHP and curl --no-buffer.

@chalasr
Copy link
Member

chalasr commented Dec 9, 2022

It would be nice to make sure that functional testing utilities are ready for this, and to patch them otherwise. Maybe even setup FrankenPHP in one of our GHA workflows?

dunglas and others added 2 commits Dec 9, 2022
…er.php

Co-authored-by: Alexander M. Turek <me@derrabus.de>
…er.php

Co-authored-by: Alexander M. Turek <me@derrabus.de>
* @return $this
*/
public function sendHeaders(): static
public function sendHeaders(/* ?int $statusCode = null */): static
{
if ($this->headersSent) {
return $this;
}

$this->headersSent = true;
Copy link
Member

@nicolas-grekas nicolas-grekas Dec 16, 2022

Choose a reason for hiding this comment

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

won't this prevent 103 from working with streamed responses?

/**
* Tracks headers already sent in informational responses
*/
private array $sentHeaders;
Copy link
Member

@nicolas-grekas nicolas-grekas Dec 16, 2022

Choose a reason for hiding this comment

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

would exposing a getter improve testability of this?

// headers
foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) {
$previousValues = $this->sentHeaders[$name] ?? null;
if ($previousValues === $values) {
// Header already sent in a previous response, it will be automatically copied in this response by PHP
Copy link
Member

@nicolas-grekas nicolas-grekas Dec 16, 2022

Choose a reason for hiding this comment

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

it will be automatically copied in this response by PHP

which part does that? frakenphp?
what if we send the headers again?
it would make sense to me, to keep things simple on our side. Up to the SAPI to do more if it wants to.
at least for 103 we must repeat the same headers in the >= 200 response (and I know no other 1xx that expect headers.)

Copy link
Member Author

@dunglas dunglas Dec 16, 2022

Choose a reason for hiding this comment

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

Yes FrankenPHP does that automatically (it delegates to Go which does it automatically too). I don't think it's necessary to introduce more complexity yet because FrankenPHP is currently the only SAPI supporting this feature, and if new SAPIs add it they will likely work the same way (if that's not the case, we'll still be able to patch at that time).

Copy link
Member

@nicolas-grekas nicolas-grekas Dec 18, 2022

Choose a reason for hiding this comment

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

As I see it, this behavior from FrankenPHP is introducing complexity on our side. I'd better have it not keep early headers in memory and rely on the app to resend its headers in the real response.
I feel like the current behavior is also not future-proof and is coupled to 103. But what if another 1xx status code decides to not require repeating some headers?

Copy link
Member Author

@dunglas dunglas Dec 18, 2022

Choose a reason for hiding this comment

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

Go and FrankenPHP do it only for 103, so it's future proof. We should probably do it only for 103 here too.

It definitely introduces some complexity on Symfony-side, but it makes it more easy for all users using PHP directly. Also, it's a bit faster.

Copy link
Member

@nicolas-grekas nicolas-grekas Dec 19, 2022

Choose a reason for hiding this comment

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

We should probably do it only for 103 here too

Definitely! I would prefer removing this special case for 103. If it's still doable, please consider my modest request :)
The complexity this requires on our side says why, no need for more arguments.

$previousValues = null;
}

$newValues = null === $previousValues ? $values : array_diff($values, $previousValues);
Copy link
Member

@nicolas-grekas nicolas-grekas Dec 16, 2022

Choose a reason for hiding this comment

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

see my previous comment: why not keep things simple and sent all headers again?

Copy link
Member Author

@dunglas dunglas Dec 16, 2022

Choose a reason for hiding this comment

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

With FrankenPHP is you don't do that headers will be duplicated.

}

/**
* @internal
Copy link
Member

@nicolas-grekas nicolas-grekas Dec 16, 2022

Choose a reason for hiding this comment

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

can be removed, the method is private

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

Successfully merging this pull request may close these issues.

None yet

7 participants