$30 off During Our Annual Pro Sale. View Details »

Juggle asynchronously with Symfony HttpClient

Juggle asynchronously with Symfony HttpClient

Making HTTP requests is a common practice, but managing their responses can quickly become a performance bottleneck. In this talk, we will explore a little-known tool, the AsyncDecoratorTrait feature of the Symfony HttpClient component, which provides more flexibility in handling request responses while preserving asynchronicity. We will discuss the application contexts in which this tool is useful and its advantages and limitations, as well as how it differs from decoration at the request or response level. Are you ready to learn more about improving HTTP response management? Let's dive in!

https://github.com/alli83/AsyncDecoratorTrait_use_case

a_guilhem

June 19, 2023
Tweet

More Decks by a_guilhem

Other Decks in Programming

Transcript

  1. Asynchronously with
    Symfony HttpClient

    View Slide

  2. Allison Guilhem
    @Les-Tilleuls.coop
    Alli_g83
    Allison E.Guilhem
    a_guilhem
    Who am I ?

    View Slide

  3. Juggle asynchronously with
    Symfony HttpClient
    Symfony HttpClient : Some Reminders
    What is the AsyncDecoratorTrait
    From a practical point of view

    View Slide

  4. Once upon a time Symfony 4.3
    ▶ composer require symfony/http-client

    View Slide

  5. HttpClient: some reminders
    - A standalone component


    - To consume APIs


    - Through synchronous and asynchronous
    operations


    - « Native PHP Streams » et cURL.


    View Slide

  6. HttpClient: some reminders
    - http_client service - stateless -
    automatically injected via type hint
    HttpClientInterface
    - Symfony/http-client-contracts: set of
    abstractions extracted from the
    component

    - Heaps of options
    public function __construct(
    private HttpClientInterface $client,
    ) {
    }
    public function test(): array
    {
    $response = $this->client->request(
    'GET',
    ‘https://url..’,
    […]
    );

    View Slide

  7. - Responses are lazy - Requests are concurrent

    - Multiplexing : stream

    - The errors are handled by default

    - Interoperable et extensible
    foreach ($client->stream($responses)

    as $response => $chunk

    ) {
    if ($chunk->isFirst()) {

    } elseif ($chunk->isLast()) {

    } else {

    }
    }
    HttpClient: some reminders

    View Slide

  8. The beginning

    View Slide

  9. A confirmed need

    View Slide

  10. - A need well asserted

    - Decorators / middlewares / event ?

    - To preserve the asynchronous dimension
    Manipulating chunks

    View Slide

  11. Some thoughts … and then

    View Slide

  12. AsyncDecoratorTrait
    - For HttpClient decorators
    - Decorators that do not consume the response to preserve the asynchronous
    dimension

    View Slide

  13. - It makes writing decorators easier and lighter

    => Symfony\…\HttpClient\DecoratorTrait
    - It's also concerning the response that tells us to wire up

    => Symfony\...\HttpClient\Response\AsyncResponse
    AsyncDecoratorTrait

    View Slide

  14. AsyncDecoratorTrait
    It will also come to connect
    itself in the stream method
    of the AsyncResponse
    A point of entry
    Core of the
    business logic

    View Slide

  15. AsyncResponse ?

    View Slide

  16. AsyncResponse: What is it ?
    class MyExtendedHttpClient implements HttpClientInterface
    {
    use AsyncDecoratorTrait;
    public function request(string $method, string $url, array $options = []):
    ResponseInterface
    {
    $passthru = function (ChunkInterface $chunk, AsyncContext $context) {
    yield $chunk;
    };
    return new AsyncResponse($this->client, $method, $url, $options, $passthru);
    }
    }

    View Slide

  17. A bit too much magic
    Let’s demystify all that

    View Slide

  18. AsyncResponse: How does it work?


    The passthru callable
    /**
    * @param ?callable(ChunkInterface, AsyncContext): ?\Iterator
    $passthru
    */
    public function __construct(HttpClientInterface $client, string
    $method, string $url, array $options, callable $passthru = null)
    {
    - AsyncResponse: a non trivial last argument

    View Slide

  19. - It represents the part of customizable logic

    - Has a dynamic and evolving de
    fi
    nition

    - A de
    fi
    nition which must respect certain rules

    $passthru = function (ChunkInterface $chunk, AsyncContext $context)
    {
    // do what you want with chunks
    yield $chunk;
    };
    AsyncResponse: How does it work?


    The passthru callable

    View Slide

  20. How do you relate this (callable) iterator to the
    AsyncResponse ?
    AsyncResponse: How does it work?


    The passthru callable

    View Slide

  21. class AsyncContext
    {
    public function __construct(
    private ?callable &$passthru,
    private HttpClientInterface $client,
    private ResponseInterface &$response,
    private array &$info,
    private /*resource*/ $content,
    private int $offset,
    ) {
    }
    AsyncResponse: How does it work?


    The passthru callable, a chunk and a context

    View Slide

  22. - A DTO which will allow us to drive the AsyncResponse

    - It's a way of modifying the response
    fl
    ow at a substantial level but also at a temporal level

    - It will permit us to interrogate the response without consuming it

    - It will give us the possibility to act on the response (cancel, replace a response, etc.)

    - It allows us to act on the iterator which controls the
    fl
    ow

    AsyncResponse: How does it work?


    The passthru callable, a chunk and a context

    View Slide

  23. Who controls what has been manipulated ?
    AsyncResponse: How does it work?


    The passthru callable

    View Slide

  24. How AsyncResponse works
    A guardian of the consistency of
    the
    fl
    ow of chunks:

    - No double
    fi
    rst chunk

    - Nothing yielded after the
    lastChunk etc….
    A generator that will emit 0,
    1 or N chunks in its output

    View Slide

  25. A simple question of timing and nesting
    AsyncDecoratorTrait

    View Slide

  26. AsyncDecoratorTrait
    - Symfony\Component\HttpClient\RetryableHttpClient
    - Symfony\Component\HttpClient\EventSourceHttpClient
    Known decorators
    For an overview of different use cases
    - Symfony\Component\HttpClient\Tests\AsyncDecoratorTraitTest

    View Slide

  27. Let’s go ?

    View Slide

  28. Don’t be afraid !

    View Slide

  29. Transclusion case
    - We had a main request on a private API and we retrieved results with paging

    - We needed to add two responses from two other API endpoints

    View Slide

  30. public function index(MyExtendedHttpClient $client): Response
    {
    $response = $client->request(
    'GET',
    ‘https://my-url.com/collection',
    [
    // options you need etc
    'user_data' => [
    'add' => [
    'availabilities' => 'https://my-url.com/collections/{id}/something',
    'posts' => 'https://my-url.com/collections/{id}/somethingelse',
    ],
    'concurrency' => null
    ],
    ]
    );

    }
    Transclusion case

    View Slide

  31. class MyExtendedHttpClient implements HttpClientInterface
    {
    use AsyncDecoratorTrait;

    public function request(string $method, string $url, array $options = []):
    ResponseInterface
    {

    $passthru = function (ChunkInterface $chunk, AsyncContext $context) use ($options) {

    }
    return new AsyncResponse($this->client, $method, $url, $options, $passthru);
    }

    }
    Transclusion case

    View Slide

  32. - Search on a favorite sector and if we take a 404 that is returned when you are
    on page 1, we will search for users in another nearby sector.

    - If we take a 404 and we are searching on pages 2, 3 or more, we do not
    restart and keep the error.
    First challenge:
    Transclusion case

    View Slide

  33. $passthru = function (ChunkInterface $chunk, AsyncContext $context) use ($options) {
    static $content = '';
    if ($chunk->isFirst()) {
    if (404 === $context->getStatusCode()) {
    $page = preg_split('/page=(\d)/', $context->getInfo()['original_url'], -1, PREG_SPLIT_DELIM_CAPTURE)[1];
    if (1 === (int) $page) {
    // look for another sector
    // do what you want
    $context->getResponse()->cancel();
    $context->replaceRequest(
    'GET',
    ‘https://my-url.com/collection', $options
    );
    return;
    }
    $context->passthru();
    }
    yield $chunk;
    }
    $content .= $chunk->getContent();
    if(!$chunk->isLast()) {
    return;
    }

    Cancel the response
    We make sure that the context
    replaces the request by another
    request
    It allows us to put our passthru back in
    a neutral position and receive a 404


    View Slide

  34. [\n
    {\n
    "id": 1,\n
    "name": "Leanne Graham",\n
    "username": "Bret",\n
    "email": "[email protected]",\n
    "address": {\n
    "street": "Kulas Light",\n
    "suite": "Apt. 556",\n
    "city": "Gwenborough",\n
    "zipcode": "92998-3874",\n
    "geo": {\n
    "lat": "-37.3159",\n
    "lng": "81.1496"\n
    }\n
    },\n
    "phone": "1-770-736-8031 x56442",\n
    },\n
    {\n ……
    }]
    JSON from main request if no error

    View Slide

  35. Second challenge:
    - What’s the best way to approach the processing of this JSON ?

    - Looking for performance and
    fl
    uidity when handling JSON is not always the
    easiest

    - We have to manipulate the response chunks to perform insertions of properties.
    Transclusion case

    View Slide

  36. We will have to parse this JSON to have
    blocks that are going to be in a form that
    can be handled as much as possible
    without any need for too heavy operations
    Why?
    Transclusion case

    View Slide

  37. array:10 [▼
    0 => '{'id':1,'name':'Leanne
    Graham','username':'Bret','email':'Sincere@april .
    biz','address':{'street':'Kulas Light','suite':'Apt .
    556','city':'Gwenborough','zipcode' ▶’
    1 => '{'id':2,'name':'Ervin
    Howell','username':'Antonette','email':'Shanna@melissa .
    tv','address':{'street':'Victor Plains','suite':'Suite
    879','city':'Wisokyburgh','z ▶'
    2 => ‘{…}

    ]
    JSON: parsedMainResponse

    View Slide

  38. public static function updateData(array $parsedMainResponse, AsyncContext $context, array $options): array
    {

    foreach ($parsedMainResponse as $mainKey => $subChunk) {
    ...
    $tempAdditionalResponses = [];

    foreach ($toAdd as $key => $url) {
    preg_match('/"id":(\d+)/',$subChunk, $match);

    $tempAdditionalResponses[] = sprintf(', "%s":', $key);
    $additionalResponse = $context->replaceRequest(
    'GET',
    $url,
    $options
    );
    $tempAdditionalResponses[] = $additionalResponse;
    }
    $tempAdditionalResponses[] = substr($subChunk, -1);

    $blocks[] = $tempAdditionalResponses;
    }
    $blocks[] = $original;
    return $blocks;
    }
    1.
    1. To identify our main request thanks
    to the context
    2.
    2.
    To manipulate the parts to consider
    a place for the insertions.
    3.
    3.
    It will be necessary to add a curly
    bracket then a separator “,”
    4.
    4.
    We have to make sure that it
    ends well on the original main
    request.


    View Slide

  39. array:7 [▼
    0 => '{'id':1,'name':'Leanne
    Graham','username':'Bret','email':'Sincere@april . biz','address':
    {'street':'Kulas Light','suite':'Apt . 556','city':'Gwenborough','zipcode' ▶'
    1 => ', 'availabilities':'
    2 => Symfony\Component\HttpClient\Response\TraceableResponse {#514 ▶}
    3 => ', 'posts':'
    4 => Symfony\Component\HttpClient\Response\TraceableResponse {#557 ▶}
    5 => '}'
    6 => ','
    ]
    One of the blocks
    Transclusion case

    View Slide

  40. //$parsedMainResponse = halaxa/json-machine ease json
    manipulation w/ PassThruDecoder
    yield $context->createChunk('[');
    // $part = block
    while (null !== $chunk = self::passthru($context,
    $part)->current()) {
    yield $chunk;
    }
    $context->passthru(static function (ChunkInterface
    $chunk, AsyncContext $context)
    use (&$parsedMainResponse, &$part, &$blocks, $options) {

    }
    Concurrent requests +
    identification of the first
    block
    An iteration until it reaches a
    ResponseInterface :
    AsyncContext - replaceReponse
    The passthru in which you
    are currently will be
    modified
    We are here
    Transclusion case

    View Slide

  41. Recap:
    Chunks from
    the main
    request
    First
    block
    Until it reaches a
    ResponseInterface
    is LastChunk ?
    Action: replace the response
    via AsyncContext
    We are here
    we cut the response of the main
    request and we put a follow-up of
    the blocks that we are handling
    Transclusion case

    View Slide

  42. Third challenge:
    - There must always be continuity to reconstruct the main response until treatment is
    complete.

    - Three important points will be dealt with:

    - When a
    fi
    rst chunk is issued

    - When a lastChunk is issued

    - And everything else

    Transclusion case

    View Slide

  43. - Has already been "issued" and
    cannot issue more than one
    fi
    rstChunk
    - It’s important to emit => in our case,
    this will be the responses to the sub-
    requests.
    if ($chunk->isFirst()) {
    return;
    }
    yield $chunk;
    Transclusion case

    View Slide

  44. What about the last chunk ?
    Should we issue it ? Should we manipulate it ?
    Badly formed request Yes but how ?
    Transclusion case

    View Slide

  45. We continue thanks to the AsyncContext
    Transclusion case

    View Slide

  46. At lastChunk, we will be in this situation
    array:4 [▼
    3 => ', 'posts':'
    4 =>
    Symfony\Component\HttpClient\
    Response\TraceableResponse
    {#550 ▶}
    5 => '}'
    6 => ','
    ]
    Block
    array:7 [▼
    0 => '{'id':1,'name':'Leanne
    Graham','username':'Bret','email':'Sinc
    ere@april . biz','address':
    {'street':'Kulas Light','suite':'Apt .
    556','city':'Gwenborough','zipcode' ▶'
    1 => ', 'availabilities':'
    2 =>
    Symfony\Component\HttpClient\Response\T
    raceableResponse {#514 ▶}
    3 => ', 'posts':'
    4 =>
    Symfony\Component\HttpClient\Response\T
    raceableResponse {#557 ▶}
    5 => '}'
    6 => ','
    ]
    Transclusion case
    Block

    View Slide

  47. Block
    Create a chunk in order to emit it
    Replace the response and all the chunks
    will be yielded
    With the AsyncContext
    array:4 [▼
    3 => ', 'posts':'
    4 =>
    Symfony\Component\HttpClient\
    Response\TraceableResponse
    {#550 ▶}
    5 => '}'
    6 => ','
    ]
    Transclusion case
    Create a chunk in order to emit it

    View Slide

  48. Block
    is LastChunk?
    Or
    Block is finished
    Block n+1
    LastChunk
    Transclusion case
    Chunks from
    the main
    request
    We cut the response of the main
    request and we put a follow-up of
    the blocks that we are handling
    Until it reaches a
    ResponseInterface
    Action: replace the response
    via AsyncContext
    Ignore FirstChunk

    View Slide

  49. Important to identify when it ends
    - And emit the lastChunk
    - You have to go back to
    the response for which you
    fi
    rst made the request
    Important to reconstruct the JSON
    ! Syntax Error !
    Transclusion case

    View Slide

  50. - We obtained some amazing performances

    - So you have all the requests that are issued in a
    concurrent way

    - It’s extremely fast

    - You have a response that is easy to manipulate
    and you keep an asynchronous frame.

    But if you have a rate limiter on
    the number of incoming requests
    ?
    Transclusion case

    View Slide

  51. public static function updateData(array $parsedMainResponse, AsyncContext $context, array $options):
    array
    {

    $concurrencyLimit = (int) $options['user_data']['concurrency'];

    foreach ($parsedMainResponse as $mainKey => $subChunk) {
    if ($concurrencyLimit && $concurrencyLimit < $mainKey) {
    break;
    }

    }

    }
    Transclusion case

    View Slide

  52. is LastChunk ? Or
    A limited number of blocks
    LastChunk
    Ignore firstChunk
    Transclusion case
    Chunks from
    the main
    request
    We cut the response of the main
    request and we put a follow-up of
    the blocks that we are handling
    Block
    Block is finished
    Until it reaches a
    ResponseInterface
    Action: replace the response
    via AsyncContext
    Block n+1

    View Slide

  53. - We have several levels that coincide
    with the limit we have put on the number
    of concurrent requests

    - You’re staying on a very good
    performance

    - AsyncContext also allows you to pause
    through one of its methods if necessary

    Transclusion case

    View Slide

  54. - Is AsyncDecoratorTrait indispensable ?

    - A winning combo « generator » + AsyncContext + AsyncResponse

    - But not for all situations either

    - A great performance when used in the right situation

    Juggle asynchronously with Symfony
    HttpClient

    View Slide

  55. Juggle asynchronously with Symfony
    HttpClient
    Thank you !


    Alli_g83
    Allison E.Guilhem
    Alli_g83
    Check this out !

    View Slide