Issue with Async Request via Guzzle

I’m facing an issue with a plugin I’m currently creating. The plugin consists of a single function that is called whenever a specific page is visited. When this page gets visited this function will call an external API using the Guzzle PHP Library and attempt to save the results in a field of this page.

This works when I go about it like this:

require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;

function updateAPIResult($page){
    $kirby = kirby();
    if(!$page){return}

    $client = new GuzzleHttp\Client();
    $response = $client->request('POST', "https://api.openai.com/v1/chat/completions", [
        'headers' => [
            'Content-Type' => 'application/json',
        ],
        'json' => [
            'mydata' => 'myvalue'
        ]
    ]);

    if($response->getStatusCode() !== 200){
        return;  // There was an error
    }

    $newValue = json_decode($response->getBody(), true)['response_value'];
    if(!$newValue) {
        return; // value not found in response
    }

    // all seems to be good, update the page
    $kirby->impersonate('kirby');
    $page = $page->update([
        'api_value' => $newValue
    ], 'en');
    $kirby->impersonate(null);
}

When this function is triggered by a user visiting this page, all works as expected. The problem I’m facing is that the API call might actually take quite a long time (10-20 seconds). The way it is set up, a user would have to wait until the response is received until they get to see the page.

I only need the user-visit to trigger the API call though. There is no need to have the data already when serving the page to the user.

That’s why I tried implementing the same behavior in an asynchronous function using Guzzles async Requests. This looks like this:

<?php 

require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\ResponseInterface;




function updateAPIResult($page){
    $kirby = kirby();
    if(!$page){return}

    $client = new GuzzleHttp\Client();
    $promise = $client->requestAsync('POST', "https://api.openai.com/v1/chat/completions", [
        'headers' => [
            'Content-Type'      => 'application/json',
        ],
        'json' => [
            'mydata' => 'myvalue'
        ]
    ]);

    $promise->then(
        function (ResponseInterface $res) use ($kirby, $page){
            $newValue = json_decode($res->getBody(), true)['response_value'];

            // update page with our new value
            $kirby->impersonate('kirby');
            $page = $page->update([
                'api_value' => $newValue
            ], 'en');
            $kirby->impersonate(null);
        },
        function (RequestException $e) use ($kirby, $page) {
            // logging the error in our field instead
            $kirby->impersonate('kirby');
            $page = $page->update([
                'api_value' => $e->getMessage()
            ], 'en');
            $kirby->impersonate(null);
        }
    );

}

In a test I made using https://httpbin/delay I could confirm that my page now loads instantly and won’t wait for the API request to be completed. Unfortunately none of the code inside the callback functions seems to have an effect and in my current setup I have little possibilities of debugging this other than logging values to fields.

It might be that I fundamentally don’t understand how promises work in this context, so I’m generally looking for a way to trigger a function that won’t force an actual user to wait.

Put differently: How can I run code that is triggered by user interaction independently from actually serving this user a response?

Don’t know why the first version works, but in both functions, $kirby is undefined (unless I overlooked something).

I wrote both versions as reduced test cases to demonstrate the issue and left out some additional code at the top of the function:

$kirby = kirby()

This might be another place that I probably don’t quite get. I thought by using use ($kirby, $page) I’m making both available to use inside the callback. If not, how do I do it then?

That’s right, but for me it looked undefined, if you post only half of the code, then this is misleading.

That makes sense. I updated my original post accordingly. Sill unsure about the asynchronous nature of this, though. Is there anything fundamental that I overlooked regarding my attempt, to serve a page immediately and update value later?

I think the async requests are aborted when the rest of the script ends. The script ends and just kills your requests off.

You can wait for a response to finish by calling wait() on the promise, but then of course you wait for the request to finish and the user has to wait again (you’re back at square one).

I see three options:

  1. you don’t call openai directly when the user visits the page, but instead serve a page which has a javascript that fetches an API of yours. That api then calls openai. This way the actual api call will still wait, but the user won’t see it because it’s just running in the background. In this case, remember to do a ignore_user_abort(true); in your API handler, so that the page continues running even if the user closes the page while the ajax request is pending
  2. similar as 1, but you call your API route from the server, asynchronously.
  3. You try to keep running after the page has rendered. This requires some deeper integration into Kirby, more than what you could do with a single function.

For option 3:
This is untested, but you would have to create a Route like this:

<?php 

return [
    [
        'pattern' => 'path/to/page',
        'action' => function () {
            ignore_user_abort(true);

            ob_start();
            site()->visit('path/to/page')->render();
            $length = ob_get_length();
            header("Content-Length: $length");
            header('Connection: close');
            header('Content-Encoding: none');
            ob_end_flush();
            flush();

            set_time_limit(0);
            // do your synchronous openai stuff here

        }
    ]
];

Thanks so much for that in-depth reply! To me, option 2 sounds like the best one – not messing too much with Kirbys inner workings and also not relying on Client-Side Javascript when its actually all about server-internal state.

I’m a little dumbfounded though on how to actually implement that. I’m confident in creating custom routes, but really I’ve no idea how I can trigger a “visit” to that route without actually waiting for a response. Do you have any suggestions/hints?

Thanks again!

You call it with a Guzzle async request, like you do now with OpenAI. The difference being that you actually really don’t care for the response.
The call to OpenAI then happens synchronously inside of that other request handler.

1 Like

Thanks so much for taking the time. This actually helped a lot. I solved the problem similaar to that solution. Only problem I faced: Like before, async requests seem to get cancelled in my setup. I solved it by basically forcing my async requests to fail after a very short timeout 0.2 seconds.

This is the solution that now works for me:

Kirby::plugin('my/plugin', [
   'routes' => [
    [
        'pattern' => 'update-api-result/(:alphanum)',
        'method' => 'GET',
        'action'  => function ($pageId) {
            ignore_user_abort(true); // keep execution of this request running even after the request itself was cancelled

            $page = page('page://' . $pageId);
            updateAPIResult($page);
        }
      ]
   ]
]);

// Called from outside the Plugin. This function calls the route defined above, but does not wait for it to respond
function triggerUpdate($page){
    $id = $page->uuid()->id(); // get id
    $url = kirby()->site()->url() . '/update-api-result/' . $id;

    $client = new \GuzzleHttp\Client(['timeout' => 0.2]);
    $promise = $client->requestAsync('GET', $url);
    try {
           $promise->wait();
    } catch (\Exception $ex) {
    }
}

// This makes the actual request that might take while
function updateAPIResult($page){
    // code to call external API 
}

The general term for behavior like this apparently is making “Fire and Forget” requests – so hopefully this might help others who get stuck on this.

Many thanks again to @rasteiner