Skip to content

Commit e88fcf3

Browse files
authored
Merge pull request #231 from clue-labs/eyeballs-errors
Improve error reporting when TCP/IP connection fails (happy eyeballs)
2 parents f2ea6e7 + 3686e51 commit e88fcf3

File tree

3 files changed

+216
-36
lines changed

3 files changed

+216
-36
lines changed

src/HappyEyeBallsConnectionBuilder.php

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,10 @@ public function connect()
7171

7272
$that->mixIpsIntoConnectQueue($ips);
7373

74-
if ($that->nextAttemptTimer instanceof TimerInterface) {
75-
return;
74+
// start next connection attempt if not already awaiting next
75+
if ($that->nextAttemptTimer === null && $that->connectQueue) {
76+
$that->check($resolve, $reject);
7677
}
77-
78-
$that->check($resolve, $reject);
7978
};
8079
};
8180

@@ -124,6 +123,12 @@ public function resolve($type, $reject)
124123
unset($that->resolverPromises[$type]);
125124
$that->resolved[$type] = true;
126125

126+
// cancel next attempt timer when there are no more IPs to connect to anymore
127+
if ($that->nextAttemptTimer !== null && !$that->connectQueue) {
128+
$that->loop->cancelTimer($that->nextAttemptTimer);
129+
$that->nextAttemptTimer = null;
130+
}
131+
127132
if ($that->hasBeenResolved() && $that->ipsCount === 0) {
128133
$that->resolverPromises = null;
129134
$reject(new \RuntimeException('Connection to ' . $that->uri . ' failed during DNS lookup: ' . $e->getMessage()));
@@ -138,15 +143,6 @@ public function resolve($type, $reject)
138143
*/
139144
public function check($resolve, $reject)
140145
{
141-
if (\count($this->connectQueue) === 0 && $this->resolved[Message::TYPE_A] === true && $this->resolved[Message::TYPE_AAAA] === true && $this->nextAttemptTimer instanceof TimerInterface) {
142-
$this->loop->cancelTimer($this->nextAttemptTimer);
143-
$this->nextAttemptTimer = null;
144-
}
145-
146-
if (\count($this->connectQueue) === 0) {
147-
return;
148-
}
149-
150146
$ip = \array_shift($this->connectQueue);
151147

152148
$that = $this;
@@ -156,7 +152,7 @@ public function check($resolve, $reject)
156152
$that->cleanUp();
157153

158154
$resolve($connection);
159-
}, function () use ($that, $ip, $reject) {
155+
}, function (\Exception $e) use ($that, $ip, $reject) {
160156
unset($that->connectionPromises[$ip]);
161157

162158
$that->failureCount++;
@@ -168,19 +164,19 @@ public function check($resolve, $reject)
168164
if ($that->ipsCount === $that->failureCount) {
169165
$that->cleanUp();
170166

171-
$reject(new \RuntimeException('All attempts to connect to "' . $that->host . '" have failed'));
167+
$reject(new \RuntimeException('Connection to ' . $that->uri . ' failed: ' . $e->getMessage()));
172168
}
173169
});
174170

175-
/**
176-
* As long as we haven't connected yet keep popping an IP address of the connect queue until one of them
177-
* succeeds or they all fail. We will wait 100ms between connection attempts as per RFC.
178-
*
179-
* @link https://tools.ietf.org/html/rfc8305#section-5
180-
*/
181-
if ((\count($this->connectQueue) > 0 || ($this->resolved[Message::TYPE_A] === false || $this->resolved[Message::TYPE_AAAA] === false)) && $this->nextAttemptTimer === null) {
182-
$this->nextAttemptTimer = $this->loop->addPeriodicTimer(self::CONNECTION_ATTEMPT_DELAY, function () use ($that, $resolve, $reject) {
183-
$that->check($resolve, $reject);
171+
// Allow next connection attempt in 100ms: https://tools.ietf.org/html/rfc8305#section-5
172+
// Only start timer when more IPs are queued or when DNS query is still pending (might add more IPs)
173+
if (\count($this->connectQueue) > 0 || $this->resolved[Message::TYPE_A] === false || $this->resolved[Message::TYPE_AAAA] === false) {
174+
$this->nextAttemptTimer = $this->loop->addTimer(self::CONNECTION_ATTEMPT_DELAY, function () use ($that, $resolve, $reject) {
175+
$that->nextAttemptTimer = null;
176+
177+
if ($that->connectQueue) {
178+
$that->check($resolve, $reject);
179+
}
184180
});
185181
}
186182
}

tests/HappyEyeBallsConnectionBuilderTest.php

Lines changed: 194 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ 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 testConnectWillStartTimerWhenIpv4ResolvesAndIpv6IsPending()
68+
public function testConnectWillStartDelayTimerWhenIpv4ResolvesAndIpv6IsPending()
6969
{
7070
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
71-
$loop->expects($this->once())->method('addTimer');
71+
$loop->expects($this->once())->method('addTimer')->with(0.05, $this->anything());
7272
$loop->expects($this->never())->method('cancelTimer');
7373

7474
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
@@ -92,10 +92,76 @@ public function testConnectWillStartTimerWhenIpv4ResolvesAndIpv6IsPending()
9292
$builder->connect();
9393
}
9494

95-
public function testConnectWillStartConnectingWithoutTimerWhenIpv6ResolvesAndIpv4IsPending()
95+
public function testConnectWillStartConnectingWithAttemptTimerButWithoutResolutionTimerWhenIpv6ResolvesAndIpv4IsPending()
9696
{
9797
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
98-
$loop->expects($this->never())->method('addTimer');
98+
$loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything());
99+
$loop->expects($this->never())->method('cancelTimer');
100+
101+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
102+
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }));
103+
104+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
105+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
106+
array('reactphp.org', Message::TYPE_AAAA),
107+
array('reactphp.org', Message::TYPE_A)
108+
)->willReturnOnConsecutiveCalls(
109+
\React\Promise\resolve(array('::1')),
110+
new Promise(function () { })
111+
);
112+
113+
$uri = 'tcp://reactphp.org:80';
114+
$host = 'reactphp.org';
115+
$parts = parse_url($uri);
116+
117+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
118+
119+
$builder->connect();
120+
}
121+
122+
public function testConnectWillStartConnectingAndWillStartNextConnectionWithNewAttemptTimerWhenNextAttemptTimerFiresWithIpv4StillPending()
123+
{
124+
$timer = null;
125+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
126+
$loop->expects($this->exactly(2))->method('addTimer')->with(0.1, $this->callback(function ($cb) use (&$timer) {
127+
$timer = $cb;
128+
return true;
129+
}));
130+
$loop->expects($this->never())->method('cancelTimer');
131+
132+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
133+
$connector->expects($this->exactly(2))->method('connect')->willReturn(new Promise(function () { }));
134+
135+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
136+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
137+
array('reactphp.org', Message::TYPE_AAAA),
138+
array('reactphp.org', Message::TYPE_A)
139+
)->willReturnOnConsecutiveCalls(
140+
\React\Promise\resolve(array('::1', '::2')),
141+
new Promise(function () { })
142+
);
143+
144+
$uri = 'tcp://reactphp.org:80';
145+
$host = 'reactphp.org';
146+
$parts = parse_url($uri);
147+
148+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
149+
150+
$builder->connect();
151+
152+
$this->assertNotNull($timer);
153+
$timer();
154+
}
155+
156+
public function testConnectWillStartConnectingAndWillDoNothingWhenNextAttemptTimerFiresWithNoOtherIps()
157+
{
158+
$timer = null;
159+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
160+
$loop->expects($this->once())->method('addTimer')->with(0.1, $this->callback(function ($cb) use (&$timer) {
161+
$timer = $cb;
162+
return true;
163+
}));
164+
$loop->expects($this->never())->method('cancelTimer');
99165

100166
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
101167
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }));
@@ -116,15 +182,93 @@ public function testConnectWillStartConnectingWithoutTimerWhenIpv6ResolvesAndIpv
116182
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
117183

118184
$builder->connect();
185+
186+
$this->assertNotNull($timer);
187+
$timer();
119188
}
120189

121-
public function testConnectWillStartTimerAndCancelTimerWhenIpv4ResolvesAndIpv6ResolvesAfterwardsAndStartConnectingToIpv6()
190+
public function testConnectWillStartConnectingWithAttemptTimerButWithoutResolutionTimerWhenIpv6ResolvesAndWillCancelAttemptTimerWhenIpv4Rejects()
122191
{
123192
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
124193
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
125-
$loop->expects($this->once())->method('addTimer')->willReturn($timer);
194+
$loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer);
126195
$loop->expects($this->once())->method('cancelTimer')->with($timer);
127-
$loop->expects($this->once())->method('addPeriodicTimer')->willReturn($this->getMockBuilder('React\EventLoop\TimerInterface')->getMock());
196+
197+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
198+
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }));
199+
200+
$deferred = new Deferred();
201+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
202+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
203+
array('reactphp.org', Message::TYPE_AAAA),
204+
array('reactphp.org', Message::TYPE_A)
205+
)->willReturnOnConsecutiveCalls(
206+
\React\Promise\resolve(array('::1')),
207+
$deferred->promise()
208+
);
209+
210+
$uri = 'tcp://reactphp.org:80';
211+
$host = 'reactphp.org';
212+
$parts = parse_url($uri);
213+
214+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
215+
216+
$builder->connect();
217+
$deferred->reject(new \RuntimeException());
218+
}
219+
220+
public function testConnectWillStartConnectingAndWillStartNextConnectionWithoutNewAttemptTimerWhenNextAttemptTimerFiresAfterIpv4Rejected()
221+
{
222+
$timer = null;
223+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
224+
$loop->expects($this->once())->method('addTimer')->with(0.1, $this->callback(function ($cb) use (&$timer) {
225+
$timer = $cb;
226+
return true;
227+
}));
228+
$loop->expects($this->never())->method('cancelTimer');
229+
230+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
231+
$connector->expects($this->exactly(2))->method('connect')->willReturn(new Promise(function () { }));
232+
233+
$deferred = new Deferred();
234+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
235+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
236+
array('reactphp.org', Message::TYPE_AAAA),
237+
array('reactphp.org', Message::TYPE_A)
238+
)->willReturnOnConsecutiveCalls(
239+
\React\Promise\resolve(array('::1', '::2')),
240+
$deferred->promise()
241+
);
242+
243+
$uri = 'tcp://reactphp.org:80';
244+
$host = 'reactphp.org';
245+
$parts = parse_url($uri);
246+
247+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
248+
249+
$builder->connect();
250+
$deferred->reject(new \RuntimeException());
251+
252+
$this->assertNotNull($timer);
253+
$timer();
254+
}
255+
256+
public function testConnectWillStartAndCancelResolutionTimerAndStartAttemptTimerWhenIpv4ResolvesAndIpv6ResolvesAfterwardsAndStartConnectingToIpv6()
257+
{
258+
$timerDelay = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
259+
$timerAttempt = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
260+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
261+
$loop->expects($this->exactly(2))->method('addTimer')->withConsecutive(
262+
array(
263+
0.05,
264+
$this->anything()
265+
),
266+
array(
267+
0.1,
268+
$this->anything()
269+
)
270+
)->willReturnOnConsecutiveCalls($timerDelay, $timerAttempt);
271+
$loop->expects($this->once())->method('cancelTimer')->with($timerDelay);
128272

129273
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
130274
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }));
@@ -149,6 +293,44 @@ public function testConnectWillStartTimerAndCancelTimerWhenIpv4ResolvesAndIpv6Re
149293
$deferred->resolve(array('::1'));
150294
}
151295

296+
public function testConnectWillRejectWhenOnlyTcpConnectionRejectsAndCancelNextAttemptTimerImmediately()
297+
{
298+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
299+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
300+
$loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer);
301+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
302+
303+
$deferred = new Deferred();
304+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
305+
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn($deferred->promise());
306+
307+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
308+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
309+
array('reactphp.org', Message::TYPE_AAAA),
310+
array('reactphp.org', Message::TYPE_A)
311+
)->willReturnOnConsecutiveCalls(
312+
\React\Promise\resolve(array('::1')),
313+
\React\Promise\reject(new \RuntimeException('ignored'))
314+
);
315+
316+
$uri = 'tcp://reactphp.org:80';
317+
$host = 'reactphp.org';
318+
$parts = parse_url($uri);
319+
320+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
321+
322+
$promise = $builder->connect();
323+
$deferred->reject(new \RuntimeException('Connection refused'));
324+
325+
$exception = null;
326+
$promise->then(null, function ($e) use (&$exception) {
327+
$exception = $e;
328+
});
329+
330+
$this->assertInstanceOf('RuntimeException', $exception);
331+
$this->assertEquals('Connection to tcp://reactphp.org:80 failed: Connection refused', $exception->getMessage());
332+
}
333+
152334
public function testCancelConnectWillRejectPromiseAndCancelBothDnsLookups()
153335
{
154336
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
@@ -193,7 +375,7 @@ public function testCancelConnectWillRejectPromiseAndCancelBothDnsLookups()
193375
$this->assertEquals('Connection to tcp://reactphp.org:80 cancelled during DNS lookup', $exception->getMessage());
194376
}
195377

196-
public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6LookupAndCancelTimer()
378+
public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6LookupAndCancelDelayTimer()
197379
{
198380
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
199381
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
@@ -230,10 +412,12 @@ public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6LookupAndC
230412
$this->assertEquals('Connection to tcp://reactphp.org:80 cancelled during DNS lookup', $exception->getMessage());
231413
}
232414

233-
public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6ConnectionAttemptAndPendingIpv4Lookup()
415+
public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6ConnectionAttemptAndPendingIpv4LookupAndCancelAttemptTimer()
234416
{
417+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
235418
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
236-
$loop->expects($this->never())->method('addTimer');
419+
$loop->expects($this->once())->method('addTimer')->with(0.1, $this->anything())->willReturn($timer);
420+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
237421

238422
$cancelled = 0;
239423
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();

tests/HappyEyeBallsConnectorTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,13 +338,13 @@ public function testRejectsWithTcpConnectorRejectionIfGivenIp()
338338

339339
/**
340340
* @expectedException RuntimeException
341-
* @expectedExceptionMessage All attempts to connect to "example.com" have failed
341+
* @expectedExceptionMessage Connection to example.com:80 failed: Connection refused
342342
* @dataProvider provideIpvAddresses
343343
*/
344344
public function testRejectsWithTcpConnectorRejectionAfterDnsIsResolved(array $ipv6, array $ipv4)
345345
{
346346
$that = $this;
347-
$promise = Promise\reject(new \RuntimeException('Connection failed'));
347+
$promise = Promise\reject(new \RuntimeException('Connection refused'));
348348
$this->resolver->expects($this->at(0))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv6));
349349
$this->resolver->expects($this->at(1))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv4));
350350
$this->tcp->expects($this->any())->method('connect')->with($this->stringContains(':80?hostname=example.com'))->willReturn($promise);

0 commit comments

Comments
 (0)