Skip to content

Commit 6db981c

Browse files
committed
Improve error reporting to include both IPv6 & IPv4 errors
1 parent 6b3aa7b commit 6db981c

File tree

3 files changed

+154
-30
lines changed

3 files changed

+154
-30
lines changed

src/HappyEyeBallsConnectionBuilder.php

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ final class HappyEyeBallsConnectionBuilder
4949
public $resolve;
5050
public $reject;
5151

52+
public $lastError6;
53+
public $lastError4;
54+
5255
public function __construct(LoopInterface $loop, ConnectorInterface $connector, ResolverInterface $resolver, $uri, $host, $parts)
5356
{
5457
$this->loop = $loop;
@@ -123,15 +126,20 @@ public function resolve($type, $reject)
123126
unset($that->resolverPromises[$type]);
124127
$that->resolved[$type] = true;
125128

129+
if ($type === Message::TYPE_A) {
130+
$that->lastError4 = $e->getMessage();
131+
} else {
132+
$that->lastError6 = $e->getMessage();
133+
}
134+
126135
// cancel next attempt timer when there are no more IPs to connect to anymore
127136
if ($that->nextAttemptTimer !== null && !$that->connectQueue) {
128137
$that->loop->cancelTimer($that->nextAttemptTimer);
129138
$that->nextAttemptTimer = null;
130139
}
131140

132141
if ($that->hasBeenResolved() && $that->ipsCount === 0) {
133-
$that->resolverPromises = null;
134-
$reject(new \RuntimeException('Connection to ' . $that->uri . ' failed during DNS lookup: ' . $e->getMessage()));
142+
$reject($that->error());
135143
}
136144

137145
throw $e;
@@ -157,11 +165,17 @@ public function check($resolve, $reject)
157165
$that->cleanUp();
158166

159167
$resolve($connection);
160-
}, function (\Exception $e) use ($that, $index, $resolve, $reject) {
168+
}, function (\Exception $e) use ($that, $index, $ip, $resolve, $reject) {
161169
unset($that->connectionPromises[$index]);
162170

163171
$that->failureCount++;
164172

173+
if (\strpos($ip, ':') === false) {
174+
$that->lastError4 = $e->getMessage();
175+
} else {
176+
$that->lastError6 = $e->getMessage();
177+
}
178+
165179
// start next connection attempt immediately on error
166180
if ($that->connectQueue) {
167181
if ($that->nextAttemptTimer !== null) {
@@ -179,7 +193,7 @@ public function check($resolve, $reject)
179193
if ($that->ipsCount === $that->failureCount) {
180194
$that->cleanUp();
181195

182-
$reject(new \RuntimeException('Connection to ' . $that->uri . ' failed: ' . $e->getMessage()));
196+
$reject($that->error());
183197
}
184198
});
185199

@@ -309,4 +323,29 @@ public function mixIpsIntoConnectQueue(array $ips)
309323
}
310324
}
311325
}
312-
}
326+
327+
/**
328+
* @internal
329+
* @return \RuntimeException
330+
*/
331+
public function error()
332+
{
333+
if ($this->lastError4 === $this->lastError6) {
334+
$message = $this->lastError6;
335+
} else {
336+
$message = 'Last error for IPv6: ' . $this->lastError6 . '. Last error for IPv4: ' . $this->lastError4;
337+
}
338+
339+
if ($this->hasBeenResolved() && $this->ipsCount === 0) {
340+
if ($this->lastError6 === $this->lastError4) {
341+
$message = ' during DNS lookup: ' . $this->lastError6;
342+
} else {
343+
$message = ' during DNS lookup. ' . $message;
344+
}
345+
} else {
346+
$message = ': ' . $message;
347+
}
348+
349+
return new \RuntimeException('Connection to ' . $this->uri . ' failed' . $message);
350+
}
351+
}

tests/HappyEyeBallsConnectionBuilderTest.php

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,40 @@ public function testConnectWillRejectWhenBothDnsLookupsReject()
6565
$this->assertEquals('Connection to tcp://reactphp.org:80 failed during DNS lookup: DNS lookup error', $exception->getMessage());
6666
}
6767

68+
public function testConnectWillRejectWhenBothDnsLookupsRejectWithDifferentMessages()
69+
{
70+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
71+
$loop->expects($this->never())->method('addTimer');
72+
73+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
74+
$connector->expects($this->never())->method('connect');
75+
76+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
77+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
78+
array('reactphp.org', Message::TYPE_AAAA),
79+
array('reactphp.org', Message::TYPE_A)
80+
)->willReturnOnConsecutiveCalls(
81+
\React\Promise\reject(new \RuntimeException('DNS6 error')),
82+
\React\Promise\reject(new \RuntimeException('DNS4 error'))
83+
);
84+
85+
$uri = 'tcp://reactphp.org:80';
86+
$host = 'reactphp.org';
87+
$parts = parse_url($uri);
88+
89+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
90+
91+
$promise = $builder->connect();
92+
93+
$exception = null;
94+
$promise->then(null, function ($e) use (&$exception) {
95+
$exception = $e;
96+
});
97+
98+
$this->assertInstanceOf('RuntimeException', $exception);
99+
$this->assertEquals('Connection to tcp://reactphp.org:80 failed during DNS lookup. Last error for IPv6: DNS6 error. Last error for IPv4: DNS4 error', $exception->getMessage());
100+
}
101+
68102
public function testConnectWillStartDelayTimerWhenIpv4ResolvesAndIpv6IsPending()
69103
{
70104
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
@@ -364,7 +398,7 @@ public function testConnectWillStartAndCancelResolutionTimerAndStartAttemptTimer
364398
$deferred->resolve(array('::1'));
365399
}
366400

367-
public function testConnectWillRejectWhenOnlyTcpConnectionRejectsAndCancelNextAttemptTimerImmediately()
401+
public function testConnectWillRejectWhenOnlyTcp6ConnectionRejectsAndCancelNextAttemptTimerImmediately()
368402
{
369403
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
370404
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
@@ -381,7 +415,81 @@ public function testConnectWillRejectWhenOnlyTcpConnectionRejectsAndCancelNextAt
381415
array('reactphp.org', Message::TYPE_A)
382416
)->willReturnOnConsecutiveCalls(
383417
\React\Promise\resolve(array('::1')),
384-
\React\Promise\reject(new \RuntimeException('ignored'))
418+
\React\Promise\reject(new \RuntimeException('DNS failed'))
419+
);
420+
421+
$uri = 'tcp://reactphp.org:80';
422+
$host = 'reactphp.org';
423+
$parts = parse_url($uri);
424+
425+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
426+
427+
$promise = $builder->connect();
428+
$deferred->reject(new \RuntimeException('Connection refused'));
429+
430+
$exception = null;
431+
$promise->then(null, function ($e) use (&$exception) {
432+
$exception = $e;
433+
});
434+
435+
$this->assertInstanceOf('RuntimeException', $exception);
436+
$this->assertEquals('Connection to tcp://reactphp.org:80 failed: Last error for IPv6: Connection refused. Last error for IPv4: DNS failed', $exception->getMessage());
437+
}
438+
439+
public function testConnectWillRejectWhenOnlyTcp4ConnectionRejectsAndWillNeverStartNextAttemptTimer()
440+
{
441+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
442+
$loop->expects($this->never())->method('addTimer');
443+
444+
$deferred = new Deferred();
445+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
446+
$connector->expects($this->once())->method('connect')->with('tcp://127.0.0.1:80?hostname=reactphp.org')->willReturn($deferred->promise());
447+
448+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
449+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
450+
array('reactphp.org', Message::TYPE_AAAA),
451+
array('reactphp.org', Message::TYPE_A)
452+
)->willReturnOnConsecutiveCalls(
453+
\React\Promise\reject(new \RuntimeException('DNS failed')),
454+
\React\Promise\resolve(array('127.0.0.1'))
455+
);
456+
457+
$uri = 'tcp://reactphp.org:80';
458+
$host = 'reactphp.org';
459+
$parts = parse_url($uri);
460+
461+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
462+
463+
$promise = $builder->connect();
464+
$deferred->reject(new \RuntimeException('Connection refused'));
465+
466+
$exception = null;
467+
$promise->then(null, function ($e) use (&$exception) {
468+
$exception = $e;
469+
});
470+
471+
$this->assertInstanceOf('RuntimeException', $exception);
472+
$this->assertEquals('Connection to tcp://reactphp.org:80 failed: Last error for IPv6: DNS failed. Last error for IPv4: Connection refused', $exception->getMessage());
473+
}
474+
475+
public function testConnectWillRejectWhenAllConnectionsRejectAndCancelNextAttemptTimerImmediately()
476+
{
477+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
478+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
479+
$loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer);
480+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
481+
482+
$deferred = new Deferred();
483+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
484+
$connector->expects($this->exactly(2))->method('connect')->willReturn($deferred->promise());
485+
486+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
487+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
488+
array('reactphp.org', Message::TYPE_AAAA),
489+
array('reactphp.org', Message::TYPE_A)
490+
)->willReturnOnConsecutiveCalls(
491+
\React\Promise\resolve(array('::1')),
492+
\React\Promise\resolve(array('127.0.0.1'))
385493
);
386494

387495
$uri = 'tcp://reactphp.org:80';

tests/HappyEyeBallsConnectorTest.php

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -289,29 +289,6 @@ public function testRejectsWithTcpConnectorRejectionIfGivenIp()
289289
$this->loop->run();
290290
}
291291

292-
/**
293-
* @expectedException RuntimeException
294-
* @expectedExceptionMessage Connection to example.com:80 failed: Connection refused
295-
* @dataProvider provideIpvAddresses
296-
*/
297-
public function testRejectsWithTcpConnectorRejectionAfterDnsIsResolved(array $ipv6, array $ipv4)
298-
{
299-
$that = $this;
300-
$promise = Promise\reject(new \RuntimeException('Connection refused'));
301-
$this->resolver->expects($this->at(0))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv6));
302-
$this->resolver->expects($this->at(1))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv4));
303-
$this->tcp->expects($this->any())->method('connect')->with($this->stringContains(':80?hostname=example.com'))->willReturn($promise);
304-
305-
$promise = $this->connector->connect('example.com:80');
306-
$this->loop->addTimer(0.1 * (count($ipv4) + count($ipv6)), function () use ($that, $promise) {
307-
$promise->cancel();
308-
309-
$that->throwRejection($promise);
310-
});
311-
312-
$this->loop->run();
313-
}
314-
315292
/**
316293
* @expectedException RuntimeException
317294
* @expectedExceptionMessage Connection to example.invalid:80 failed during DNS lookup: DNS error

0 commit comments

Comments
 (0)