Search code examples
restperlpromiseconcurrencymojolicious

Perl Mojolicious, what's the correct way to render a response from a Promise?


In Mojolicious, what's the correct way to render a response from a Promise? In my code below, I get:

[2023-04-12] [trace] Template "example/search.html.ep" not found
[2023-04-12] [trace] Nothing has been rendered, expecting delayed response
  1. Why is it looking for example/search.html.ep? I didn't ask for it.
  2. I am rendering content... Mojo couldn't wait?

My sample app has 3 files:

1. script/my_app

#!/usr/bin/env perl

use strict;
use warnings;

use Mojo::File qw(curfile);
use lib curfile->dirname->sibling('lib')->to_string;
use Mojolicious::Commands;

Mojolicious::Commands->start_app('MyApp');

2. lib/MyApp.pm

package MyApp;
use Mojo::Base 'Mojolicious', -signatures;

sub startup ($self) {
    $self->secrets('s3cret');
    my $r = $self->routes;
    $r->get('/')->to('Example#index');
    $r->post('/')->to('Example#search');
}

1;

3. lib/MyApp/Controller/Example.pm

package MyApp::Controller::Example;
use Mojo::Base 'Mojolicious::Controller', -signatures;

sub index ($self)
{
    return $self->render(inline => '<html><body><form method="post"><textarea name="numbers" maxlength="11">123</textarea><button type="submit">Go</button></form></body></html>');
}

sub search ($self)
{
    my $v = $self->validation;
    $v->required("numbers");
    return $self->render(text=>"Validation Error") if $v->has_error;
    my $numbers = $v->param("numbers");
    my @numbers = split(/\r?\n/, $numbers);

    Mojo::Promise
    ->map(
    {concurrency => 2},
    sub {
        $self->ua->get_p("https://httpbin.org/delay/1?q=$_" => {'api-key'=>'shhh'});
    }, @numbers)
    ->then(
    sub{
        my @results = @_;
        my @json = map { $_->[0]->res->json } @results;
        return $self->render(json => \@json);
    })
    ->catch(
    sub {
        my $err = shift;
        return $self->render(text => $err);
    })
    ->wait;
    #return $self->render(text => "This shall result is 'Unhandled rejected promise: A response has already been rendered'");
}

1;

To run the app, I do:

morbo script/my_app

then navigate to http://localhost:3000 and post the form. Multiple lines will result in concurrent calls.


Solution

  • Add a render_later before invoking the Promise:

    $self->render_later();
    
    Mojo::Promise
      ->map(
        {concurrency => 2},
    ...
    

    Without it, when the function search exits, Mojolicious will by default render the associated template (example/search.html.ep), and fail because this template doesn't exist (see Automatic rendering in Mojolicious::Guide::Rendering). The render_later disables this automatic rendering, basically telling Mojolicious "keep the connection open, I'll render something later".


    Your confusion may come from the use of wait, which doesn't really "wait" in this context. If you add a print before and after the whole promise thing:

    say "Before";
    Mojo::Promise->map(...);
    say "After";
    

    Then the log will be:

    [2023-04-13 09:23:08.78601] [1201283] [trace] [rR7TxcCsrygT] POST "/"
    [2023-04-13 09:23:08.78692] [1201283] [trace] [rR7TxcCsrygT] Routing to controller "MyApp::Controller::Example" and action "search"
    Before
    After
    [2023-04-13 09:23:08.78953] [1201283] [trace] [rR7TxcCsrygT] Template "example/search.html.ep" not found
    ...
    

    Which does show that the wait didn't really wait (if you're not convinced, you can even add prints in the then and catch: they'll be printed after After).

    Looking at the documentation of wait, it says:

    Start "ioloop" and stop it again once the promise has been fulfilled or rejected, does nothing when "ioloop" is already running.

    Here, since you are running a server, the IOLoop is always running, so wait does nothing. This is actually desirable: if wait were to actually wait, then there would be no point in using a Promise.
    On the other hand, when you use Mojo::Promise on its own (ie, not in a Mojolicious server), you might need to manually run the IOLoop with wait in order to not exit your script before your promise is finished.