API-Docker
view release on metacpan or search on metacpan
t/connect_timeout.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use Errno;
use File::Temp qw( tempdir );
use IO::Socket::UNIX;
use Time::HiRes qw( time );
use API::Docker;
use API::Docker::Error::Timeout;
# The connect timeout of API::Docker::Role::HTTP (karr k61).
#
# read_timeout (karr k59) bounds reading and nothing else, so a daemon that
# accepts and then says nothing is caught while a socket that never accepts is
# not. connect_timeout is the other half.
#
# Nothing here reaches a Docker daemon and nothing here touches the network:
# the one end-to-end assertion runs against a local Unix listener whose backlog
# has been filled, which is the only way an AF_UNIX connect blocks at all. So
# there is no is_live()/can_write() gating -- it is unconditionally safe with
# no engine installed.
# A client that records what _build__socket was handed instead of connecting,
# so the per-request resolution can be asserted at the seam it has to cross.
package Test::ConnectTimeout::Probe;
use Moo;
extends 'API::Docker';
has seen => (is => 'ro', required => 1);
has pending => (is => 'ro', default => sub { [] });
sub _build__socket {
my ($self) = @_;
my $pending = $self->_pending_connect;
push @{ $self->seen }, $pending ? $pending->{timeout} : 'no pending';
push @{ $self->pending }, $pending;
die "no socket here\n";
}
package main;
my $client = API::Docker->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
# ---------------------------------------------------------------------------
subtest 'the attribute is off by default and off when set to 0' => sub {
is $client->connect_timeout, undef,
'no default -- every existing caller connects exactly as it always did';
is $client->_connect_timeout_value(undef), undef, 'undef is off';
is $client->_connect_timeout_value(0), undef,
'0 is off too, which is how a client-wide default is turned off for one '
. 'request';
is $client->_connect_timeout_value(2.5), 2.5, 'a fraction is kept as one';
my $set = API::Docker->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
connect_timeout => 5,
);
is $set->connect_timeout, 5, 'and it is a constructor argument';
};
subtest 'a value that is not a number is refused, not rounded' => sub {
for my $bad ('soon', '', [], {}, -1) {
eval { $client->_connect_timeout_value($bad) };
like $@, qr/connect_timeout must be a non-negative number/,
'refused: ' . (ref $bad || "'$bad'");
}
# The message names connect_timeout rather than read_timeout: the two share
# one check, and a caller who mistyped one must not be told about the other.
eval { $client->_read_timeout_value('soon') };
like $@, qr/read_timeout must be a non-negative number/,
'and the read timeout still names itself';
};
# ---------------------------------------------------------------------------
# What counts as the bound firing, and what does not. This is the whole of the
# discrimination: a refused connection and a missing socket path are diagnoses
# the caller can act on, and reporting them as a timeout would replace one with
# a cause that is not true.
subtest '_connect_expired: only the bound firing counts as a timeout' => sub {
t/connect_timeout.t view on Meta::CPAN
for (1 .. 200) {
my $c = IO::Socket::UNIX->new(
Peer => $path, Type => SOCK_STREAM, Timeout => 1);
unless ($c) {
$full = ($! == Errno::EAGAIN() || $! == Errno::EWOULDBLOCK()) ? 1 : 0;
last;
}
push @held, $c;
}
plan skip_all => 'the listen backlog does not fill the way this platform '
. 'was measured to' unless $full;
my $blocked = API::Docker->new(
host => 'unix://' . $path,
api_version => '1.41',
connect_timeout => 1,
);
# The alarm is the harness's own bound, not the one under test: without the
# Timeout on the constructor this connect blocks forever, and a test that
# hangs reports nothing. With it, the alarm never fires.
my $t0 = time;
eval {
local $SIG{ALRM} = sub { die "the connect was never bounded at all\n" };
alarm 10;
$blocked->get('/probe');
alarm 0;
1;
};
my $err = $@;
alarm 0;
my $elapsed = time - $t0;
isa_ok $err, 'API::Docker::Error::Timeout';
is ref $err && $err->phase, 'connect',
'the phase says the daemon was never reached';
is ref $err && $err->timeout, 1, 'and names the bound that expired';
is ref $err && $err->partial, '',
'with no partial: there is no response to have part of';
is ref $err && $err->summary, undef, 'and no summary';
like "$err", qr{\QGET /v1.41/probe\E},
'the request is named in the string';
like "$err", qr/connect timeout/, 'and it says which bound this was';
cmp_ok $elapsed, '<', 5,
'it gave up rather than hanging (' . sprintf('%.2fs', $elapsed) . ')';
# Without the bound this same connect blocks indefinitely, so it is not
# attempted here -- the assertion above is that the bound is what ended it,
# and the measurement that it blocks without one is in the POD.
close $_ for @held;
close $srv;
};
# ---------------------------------------------------------------------------
subtest 'the per-request option overrides the attribute either way' => sub {
# What reached _build__socket, read where the constructors read it. Nothing
# about this can be seen from the outside -- the connect either blocks or it
# does not -- so the assertion is made at the seam the value has to cross.
my @seen;
my $probe = Test::ConnectTimeout::Probe->new(
host => 'unix:///nonexistent-api-docker-61.sock',
api_version => '1.41',
connect_timeout => 5,
seen => \@seen,
);
eval { $probe->get('/probe') };
eval { $probe->get('/probe', connect_timeout => 2) };
# Resolved with exists rather than truth, so 0 is "wait as long as it takes"
# rather than "no opinion", which is how a client-wide default is turned off
# for one request.
eval { $probe->get('/probe', connect_timeout => 0) };
is_deeply \@seen, [5, 2, undef],
'the attribute, then the option overriding it, then an explicit 0 '
. 'turning it off';
is_deeply [ map { $_->{endpoint} } @{ $probe->pending } ],
[ ('GET /v1.41/probe') x 3 ],
'and the endpoint travels with it, so the exception can name the request';
};
subtest 'the pending bound does not outlive the request that set it' => sub {
my $missing = API::Docker->new(
host => 'unix:///nonexistent-api-docker-61.sock',
api_version => '1.41',
connect_timeout => 5,
);
eval { $missing->get('/probe') };
is $missing->_pending_connect, undef,
'cleared even though the connect croaked -- a later socket built by '
. 'anything but a request must not inherit the last one';
};
subtest 'a read timeout still says it was a read' => sub {
# The default of the new attribute, asserted where it is cheap: every
# Error::Timeout raised before this existed described a read.
my $err = API::Docker::Error::Timeout->new(
message => 'x', timeout => 2);
is $err->phase, 'read', 'phase defaults to read';
};
done_testing;
( run in 1.263 second using v1.01-cache-2.11-cpan-364913b4093 )