php-http / httplug

HTTPlug, the HTTP client abstraction for PHP
http://httplug.io
MIT License
2.58k stars 39 forks source link

State of async HTTP clients and promises #165

Open filips123 opened 4 years ago

filips123 commented 4 years ago

I'd say PSR-18 was pretty successful. Guzzle, Buzz, Symfony and others support it natively, and some others support it via adapters.

However, this still doesn't help you very much if you need async HTTP requests. Although there is HttpAsyncClient in this package, it is still not standard, not many clients support it without adapters and some adapters seem to be outdated: Guzzle adapters do not support Guzzle 7 yet (which added PSR-18 support but does not support HttpAsyncClient promises), Buzz does not support HttpAsyncClient and adapter was discontinued... From popular clients, only Symfony supports it natively. This will become better when Guzzle 7 HttpAsyncClient adapter is released, but still not perfect.

So, will async HTTP clients (and promises which are requirement for async HTTP client PSR) ever become PSR standard and be directly supported in popular clients? What is current state of becoming standard? From quick (possibly incomplete) search through docs and closed issues, it seems there were some plans to make promise, event loop and async HTTP client PSR. However, discussions about this seem to have ended few years ago and some were "put on hold" until future.

dbu commented 4 years ago

my understanding is that the PSR for promises would need to be restarted. promises might need to consider event loop, though "we" (as in, the async http client) is not really concerned with event loop, async http requests are useful in classic PHP applications as well to just parallelize several http requests.

when promises get accepted, we can pick up the async http client topic again.

for the state of a promises PSR, i guess it would be best to inquire with the fig, maybe on their mailing list. afaik its a tricky topic to get right, but maybe ideas/opinions/technology has evolved since the last attempt?

joelwurtz commented 3 years ago

I think we can close this, with fiber in php 8.1 async or sync can have the same API so this is no longer necessary,

I believe next goal would be to deprecate async interface, create an async implementation of PSR 18 (with amp http client v3 by example) and add an example on how to do parallel request with fiber + psr 18

dbu commented 3 years ago

that sounds very interesting! i did not look into fiber yet. is it some sort of "threads"? so we could have the requests block but people use multiple fiber threads to do parallel requests?

why would we need a specific client for it to work? afaik PSR-18 can not return a promise but only the real result. what does a client such as amp do different than e.g. guzzle that we can leverage here?

veewee commented 2 years ago

@dbu

Fibers can deal with async IO internally without having to deal with promises. This means that the existing PSR-18 interface can be used for both async and sync requests. An event-loop (like revolt) must be used in order to keep track of the suspended fibers.

Amp is working on v3 of amp and v5 of their http client which is based on fibers. This means that it will probably also be included in Symfony/http-client at some point, since they use amp internally. Resulting in an async fiber based psr-18 client.

See examples of new http client here: https://github.com/amphp/http-client/blob/8603d69bffa33f805b7009b58637c692340c65aa/examples/concurrency/1-concurrent-fetch.php#L1

I'dd say that the async client interface might still make sense for non-fiber based clients like e.g. guzzle at this point in time.

Fiber based clients could even make the promise->wait() method non-blocking.

So even if there would be fiber based psr-18 clients, it might make sense to keep the interface nevertheless. My guess is that there most likely won't come a PSR for async clients, since we have fibers.

dbu commented 2 years ago

thanks for that update @veewee

it seems to me like we should eventually rework the doc section on promises now that fibers exist.

just to make sure i understand correctly: when using fibers and PSR-18, from the PHP point of view, you'd start a fiber for each request and have that fiber blocked until the request is finished?

veewee commented 2 years ago

It depends on the implementation. Fibers are meant to be non-blocking: you suspend code execution to a future time, if a specific condition is met.

You indeed start a fiber for every request. After sending the request, there is a moment that the server must wait for a response. At that moment, the fiber is suspended until there is something to read on the socket. The implementation can be suspended again for example for chunked responses etc.

The idea is that the event-loop keeps track of all these suspended states. After sending multiple requests, you can wait for all requests to be finished. None of these waits is blocking the other requests. Meaning that you can actually parse some responses before an other one has finished responding. This parsing can be done whilst the event-loop is waiting for a socket to become readable again.

dbu commented 2 years ago

i stumbled over https://packagist.org/packages/symplely/hyper which implements psr-18. i did not have time to investigate much, but if i get the idea correctly, fibers might allow async programs to interact with a psr-18 client.

dbu commented 2 years ago

oh, if i get it correctly, symplely/hyper depends on a php extension libuv and is not using fibers, so not really a general solution.

veewee commented 2 years ago

@dbu

https://github.com/veewee/reactphp-soap/blob/php-soap-psr18/src/Protocol/Psr18Browser.php

This is an example implementation of a fully async fiber based react psr-18 client. Externally, it behaved like a regular psr-18 client. Yet you can await the request call to make it work async behind the curtains.

It will probably be sufficient to provide a similar looking adapter somewherein a httplug package in order to make it composer requireable.

dbu commented 2 years ago

one of these days i really need to catch up on fibers. but actually, do we even need to provide a special client? or would it be enough for the caller to call await($client->sendRequest(...)) to only block that fiber instead of the whole process? if i understand fibers correctly, they do not use a promise pattern, but are used like threads? so you would start a thread to do the web request and possibly wait somewhere for the thread to finish.

or what would the job of a psr-18 fiber adapter be?

veewee commented 2 years ago

Both react and amp have their own fiber based http-client implementation, with their own response / request objects. They do not provide a PSR-18 version of that client. So the adapter package would provide a PSR-18 client interface for those http clients. For example a React PSR18 client might look like this:

use Http\Client\HttpClient;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use React\Http\Browser;
use function React\Async\await;

final class Psr18Browser implements HttpClient
{
    public function __construct(
        private Browser $browser
    ){
    }

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        return await(
            $this->browser->request(
                $request->getMethod(),
                (string) $request->getUri(),
                $request->getHeaders(),
                (string) $request->getBody()
            )
        );
    }
}

As you can see, the interface looks as-is you were calling the API in a sync way. So in this case, you could just call:

$response = $client->sendRequest(xxx);

It will return a response, just like in a sync API - but react will make sure that it happens in a concurrent non-blocking way underneath. This is done by fibers. React provides an event-loop that waits for I/O and resumes the fiber when the data is available. This makes it possible to do multiple requests at once (since most of the time is just waiting for the response).

The main take-away from above, is that it does I/O operations in a non-blocking way in a single thread. Since most of the time you are waithing for the request stream to be sent or the response stream being received, fibers make sure that other code can be executed whilst you are waiting for that I/O. Yet all happens in a single thread, so no code gets executed concurrently. It's all about waiting for the I/O.

You can transform the API to an async (promise based) API like this:

$promise = async(fn () => $client->sendRequest(xxx));

Yet, since it is all fiber based, I/O in both examples will be non-blocking. You will just have another way of dealing with the response from a PHP code point of view.

This indeed means that there is no need for a new client interface in httplug and you could even remove the async and promise implementations in favour for the fiber based approach.

To give an example, plugins/middleware might look like this:

interface Plugin
{
    public function handleRequest(RequestInterface $request, callable $next, callable $first): Response;
}

class SomePlugin implements Plugin
{
    public function handleRequest(RequestInterface $request, callable $next, callable $first): Response
    {
        try {
            $response = $next($request->withX());
        } catch (NetworkException $e) {
            log('xxx');
            throw $e;
        }

        return $response->withX();
    }
}

This makes it possible to use the same plugins in both sync and async psr-18 implementations. Since there is no need for a promise anymore. As mentioned above - if you do want to have promises, you can still do

$promise = async(fn() => $next($request->withX()));

From this package's view, you dont really have to care if the implementation is fiber based or just regular PSR-18. It depends on the implementation if it behaves async or not.

For fiber based implementations, this would run in "parallel". For regular PSR-18 implementations, this will run in "series" (from a I/O point of view of course - we dont have threads)

$responses = parallel([
    async(fn() => $client->request($a)),
    async(fn() => $client->request($b)),
    async(fn() => $client->request($c)),
]);

More info about functions and components used above:

I hope this comment clearifies things. Because it's quite confusing, yet very easy :)

bartvanhoutte commented 2 years ago

Also see https://github.com/php-http/react-adapter/issues/49

WyriHaximus commented 2 years ago

@veewee Thanks for the details explanation on PSR-18, we're looking into it at the moment, no promises, but with fibers PSR-18 and such are very much likely to be in reach.