diff --git a/src/HappyEyeBallsConnectionBuilder.php b/src/HappyEyeBallsConnectionBuilder.php index 0f610cd0..9b472ac1 100644 --- a/src/HappyEyeBallsConnectionBuilder.php +++ b/src/HappyEyeBallsConnectionBuilder.php @@ -49,6 +49,10 @@ final class HappyEyeBallsConnectionBuilder public $resolve; public $reject; + public $lastErrorFamily; + public $lastError6; + public $lastError4; + public function __construct(LoopInterface $loop, ConnectorInterface $connector, ResolverInterface $resolver, $uri, $host, $parts) { $this->loop = $loop; @@ -123,6 +127,14 @@ public function resolve($type, $reject) unset($that->resolverPromises[$type]); $that->resolved[$type] = true; + if ($type === Message::TYPE_A) { + $that->lastError4 = $e->getMessage(); + $that->lastErrorFamily = 4; + } else { + $that->lastError6 = $e->getMessage(); + $that->lastErrorFamily = 6; + } + // cancel next attempt timer when there are no more IPs to connect to anymore if ($that->nextAttemptTimer !== null && !$that->connectQueue) { $that->loop->cancelTimer($that->nextAttemptTimer); @@ -130,8 +142,7 @@ public function resolve($type, $reject) } if ($that->hasBeenResolved() && $that->ipsCount === 0) { - $that->resolverPromises = null; - $reject(new \RuntimeException('Connection to ' . $that->uri . ' failed during DNS lookup: ' . $e->getMessage())); + $reject(new \RuntimeException($that->error())); } throw $e; @@ -157,11 +168,19 @@ public function check($resolve, $reject) $that->cleanUp(); $resolve($connection); - }, function (\Exception $e) use ($that, $index, $resolve, $reject) { + }, function (\Exception $e) use ($that, $index, $ip, $resolve, $reject) { unset($that->connectionPromises[$index]); $that->failureCount++; + if (\strpos($ip, ':') === false) { + $that->lastError4 = $e->getMessage(); + $that->lastErrorFamily = 4; + } else { + $that->lastError6 = $e->getMessage(); + $that->lastErrorFamily = 6; + } + // start next connection attempt immediately on error if ($that->connectQueue) { if ($that->nextAttemptTimer !== null) { @@ -179,7 +198,7 @@ public function check($resolve, $reject) if ($that->ipsCount === $that->failureCount) { $that->cleanUp(); - $reject(new \RuntimeException('Connection to ' . $that->uri . ' failed: ' . $e->getMessage())); + $reject(new \RuntimeException($that->error())); } }); @@ -309,4 +328,31 @@ public function mixIpsIntoConnectQueue(array $ips) } } } -} \ No newline at end of file + + /** + * @internal + * @return string + */ + public function error() + { + if ($this->lastError4 === $this->lastError6) { + $message = $this->lastError6; + } elseif ($this->lastErrorFamily === 6) { + $message = 'Last error for IPv6: ' . $this->lastError6 . '. Previous error for IPv4: ' . $this->lastError4; + } else { + $message = 'Last error for IPv4: ' . $this->lastError4 . '. Previous error for IPv6: ' . $this->lastError6; + } + + if ($this->hasBeenResolved() && $this->ipsCount === 0) { + if ($this->lastError6 === $this->lastError4) { + $message = ' during DNS lookup: ' . $this->lastError6; + } else { + $message = ' during DNS lookup. ' . $message; + } + } else { + $message = ': ' . $message; + } + + return 'Connection to ' . $this->uri . ' failed' . $message; + } +} diff --git a/tests/HappyEyeBallsConnectionBuilderTest.php b/tests/HappyEyeBallsConnectionBuilderTest.php index 4521ab09..52e47ef4 100644 --- a/tests/HappyEyeBallsConnectionBuilderTest.php +++ b/tests/HappyEyeBallsConnectionBuilderTest.php @@ -65,6 +65,42 @@ public function testConnectWillRejectWhenBothDnsLookupsReject() $this->assertEquals('Connection to tcp://reactphp.org:80 failed during DNS lookup: DNS lookup error', $exception->getMessage()); } + public function testConnectWillRejectWhenBothDnsLookupsRejectWithDifferentMessages() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->never())->method('connect'); + + $deferred = new Deferred(); + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + array('reactphp.org', Message::TYPE_AAAA), + array('reactphp.org', Message::TYPE_A) + )->willReturnOnConsecutiveCalls( + $deferred->promise(), + \React\Promise\reject(new \RuntimeException('DNS4 error')) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException('DNS6 error')); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Connection to tcp://reactphp.org:80 failed during DNS lookup. Last error for IPv6: DNS6 error. Previous error for IPv4: DNS4 error', $exception->getMessage()); + } + public function testConnectWillStartDelayTimerWhenIpv4ResolvesAndIpv6IsPending() { $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -364,7 +400,7 @@ public function testConnectWillStartAndCancelResolutionTimerAndStartAttemptTimer $deferred->resolve(array('::1')); } - public function testConnectWillRejectWhenOnlyTcpConnectionRejectsAndCancelNextAttemptTimerImmediately() + public function testConnectWillRejectWhenOnlyTcp6ConnectionRejectsAndCancelNextAttemptTimerImmediately() { $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -381,7 +417,81 @@ public function testConnectWillRejectWhenOnlyTcpConnectionRejectsAndCancelNextAt array('reactphp.org', Message::TYPE_A) )->willReturnOnConsecutiveCalls( \React\Promise\resolve(array('::1')), - \React\Promise\reject(new \RuntimeException('ignored')) + \React\Promise\reject(new \RuntimeException('DNS failed')) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException('Connection refused')); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Connection to tcp://reactphp.org:80 failed: Last error for IPv6: Connection refused. Previous error for IPv4: DNS failed', $exception->getMessage()); + } + + public function testConnectWillRejectWhenOnlyTcp4ConnectionRejectsAndWillNeverStartNextAttemptTimer() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + + $deferred = new Deferred(); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tcp://127.0.0.1:80?hostname=reactphp.org')->willReturn($deferred->promise()); + + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + array('reactphp.org', Message::TYPE_AAAA), + array('reactphp.org', Message::TYPE_A) + )->willReturnOnConsecutiveCalls( + \React\Promise\reject(new \RuntimeException('DNS failed')), + \React\Promise\resolve(array('127.0.0.1')) + ); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $promise = $builder->connect(); + $deferred->reject(new \RuntimeException('Connection refused')); + + $exception = null; + $promise->then(null, function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Connection to tcp://reactphp.org:80 failed: Last error for IPv4: Connection refused. Previous error for IPv6: DNS failed', $exception->getMessage()); + } + + public function testConnectWillRejectWhenAllConnectionsRejectAndCancelNextAttemptTimerImmediately() + { + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $deferred = new Deferred(); + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->exactly(2))->method('connect')->willReturn($deferred->promise()); + + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + $resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive( + array('reactphp.org', Message::TYPE_AAAA), + array('reactphp.org', Message::TYPE_A) + )->willReturnOnConsecutiveCalls( + \React\Promise\resolve(array('::1')), + \React\Promise\resolve(array('127.0.0.1')) ); $uri = 'tcp://reactphp.org:80'; diff --git a/tests/HappyEyeBallsConnectorTest.php b/tests/HappyEyeBallsConnectorTest.php index 6c1c24a6..21cc1028 100644 --- a/tests/HappyEyeBallsConnectorTest.php +++ b/tests/HappyEyeBallsConnectorTest.php @@ -289,29 +289,6 @@ public function testRejectsWithTcpConnectorRejectionIfGivenIp() $this->loop->run(); } - /** - * @expectedException RuntimeException - * @expectedExceptionMessage Connection to example.com:80 failed: Connection refused - * @dataProvider provideIpvAddresses - */ - public function testRejectsWithTcpConnectorRejectionAfterDnsIsResolved(array $ipv6, array $ipv4) - { - $that = $this; - $promise = Promise\reject(new \RuntimeException('Connection refused')); - $this->resolver->expects($this->at(0))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv6)); - $this->resolver->expects($this->at(1))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv4)); - $this->tcp->expects($this->any())->method('connect')->with($this->stringContains(':80?hostname=example.com'))->willReturn($promise); - - $promise = $this->connector->connect('example.com:80'); - $this->loop->addTimer(0.1 * (count($ipv4) + count($ipv6)), function () use ($that, $promise) { - $promise->cancel(); - - $that->throwRejection($promise); - }); - - $this->loop->run(); - } - /** * @expectedException RuntimeException * @expectedExceptionMessage Connection to example.invalid:80 failed during DNS lookup: DNS error