Skip to content

Commit 67f9f21

Browse files
authored
Add read timeout (#169)
1 parent 98489af commit 67f9f21

File tree

3 files changed

+457
-14
lines changed

3 files changed

+457
-14
lines changed

src/main/java/com/apptasticsoftware/rssreader/AbstractRssReader.java

+82-14
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,13 @@
6464
*/
6565
public abstract class AbstractRssReader<C extends Channel, I extends Item> {
6666
private static final String LOG_GROUP = "com.apptasticsoftware.rssreader";
67+
private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(1);
6768
private final HttpClient httpClient;
6869
private DateTimeParser dateTimeParser = new DateTime();
6970
private String userAgent = "";
71+
private Duration connectionTimeout = Duration.ofSeconds(25);
72+
private Duration requestTimeout = Duration.ofSeconds(25);
73+
private Duration readTimeout = Duration.ofSeconds(25);
7074
private final Map<String, String> headers = new HashMap<>();
7175
private final HashMap<String, BiConsumer<C, String>> channelTags = new HashMap<>();
7276
private final HashMap<String, Map<String, BiConsumer<C, String>>> channelAttributes = new HashMap<>();
@@ -240,7 +244,7 @@ protected void registerItemAttributes() {
240244
}
241245

242246
/**
243-
* Date and Time parser for parsing timestamps.
247+
* Date and time parser for parsing timestamps.
244248
* @param dateTimeParser the date time parser to use.
245249
* @return updated RSSReader.
246250
*/
@@ -252,8 +256,8 @@ public AbstractRssReader<C, I> setDateTimeParser(DateTimeParser dateTimeParser)
252256
}
253257

254258
/**
255-
* Sets the user-agent of the HttpClient.
256-
* This is completely optional and if not set then it will not send a user-agent header.
259+
* Sets the user-agent of the http client.
260+
* Optional parameter if not set the default value for {@code java.net.http.HttpClient} will be used.
257261
* @param userAgent the user-agent to use.
258262
* @return updated RSSReader.
259263
*/
@@ -265,8 +269,7 @@ public AbstractRssReader<C, I> setUserAgent(String userAgent) {
265269
}
266270

267271
/**
268-
* Adds a http header to the HttpClient.
269-
* This is completely optional and if no headers are set then it will not add anything.
272+
* Adds a http header to the http client.
270273
* @param key the key name of the header.
271274
* @param value the value of the header.
272275
* @return updated RSSReader.
@@ -279,6 +282,58 @@ public AbstractRssReader<C, I> addHeader(String key, String value) {
279282
return this;
280283
}
281284

285+
/**
286+
* Sets the connection timeout for the http client.
287+
* The connection timeout is the time it takes to establish a connection to the server.
288+
* If set to zero the default value for {@link java.net.http.HttpClient.Builder#connectTimeout(Duration)} will be used.
289+
* Default: 25 seconds.
290+
*
291+
* @param connectionTimeout the timeout duration.
292+
* @return updated RSSReader.
293+
*/
294+
public AbstractRssReader<C, I> setConnectionTimeout(Duration connectionTimeout) {
295+
validate(connectionTimeout, "Connection timeout");
296+
this.connectionTimeout = connectionTimeout;
297+
return this;
298+
}
299+
300+
/**
301+
* Sets the request timeout for the http client.
302+
* The request timeout is the time between the request is sent and the first byte of the response is received.
303+
* If set to zero the default value for {@link java.net.http.HttpRequest.Builder#timeout(Duration)} will be used.
304+
* Default: 25 seconds.
305+
*
306+
* @param requestTimeout the timeout duration.
307+
* @return updated RSSReader.
308+
*/
309+
public AbstractRssReader<C, I> setRequestTimeout(Duration requestTimeout) {
310+
validate(requestTimeout, "Request timeout");
311+
this.requestTimeout = requestTimeout;
312+
return this;
313+
}
314+
315+
/**
316+
* Sets the read timeout.
317+
* The read timeout it the time for reading all data in the response body.
318+
* The effect of setting the timeout to zero is the same as setting an infinite Duration, ie. block forever.
319+
* Default: 25 seconds.
320+
*
321+
* @param readTimeout the timeout duration.
322+
* @return updated RSSReader.
323+
*/
324+
public AbstractRssReader<C, I> setReadTimeout(Duration readTimeout) {
325+
validate(readTimeout, "Read timeout");
326+
this.readTimeout = readTimeout;
327+
return this;
328+
}
329+
330+
private void validate(Duration duration, String name) {
331+
Objects.requireNonNull(duration, name + " must not be null");
332+
if (duration.isNegative()) {
333+
throw new IllegalArgumentException(name + " must not be negative");
334+
}
335+
}
336+
282337
/**
283338
* Add item extension for tags
284339
* @param tag - tag name
@@ -450,8 +505,10 @@ public CompletableFuture<Stream<I>> readAsync(String url) {
450505
*/
451506
protected CompletableFuture<HttpResponse<InputStream>> sendAsyncRequest(String url) {
452507
var builder = HttpRequest.newBuilder(URI.create(url))
453-
.timeout(Duration.ofSeconds(25))
454508
.header("Accept-Encoding", "gzip");
509+
if (requestTimeout.toMillis() > 0) {
510+
builder.timeout(requestTimeout);
511+
}
455512

456513
if (!userAgent.isBlank())
457514
builder.header("User-Agent", userAgent);
@@ -510,6 +567,7 @@ class RssItemIterator implements Iterator<I> {
510567
private I nextItem;
511568
private boolean isChannelPart = false;
512569
private boolean isItemPart = false;
570+
private ScheduledFuture<?> parseWatchdog;
513571

514572
public RssItemIterator(InputStream is) {
515573
this.is = is;
@@ -528,6 +586,9 @@ public RssItemIterator(InputStream is) {
528586
xmlInFact.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, Boolean.FALSE);
529587

530588
reader = xmlInFact.createXMLStreamReader(is);
589+
if (!readTimeout.isZero()) {
590+
parseWatchdog = EXECUTOR.schedule(this::close, readTimeout.toMillis(), TimeUnit.MILLISECONDS);
591+
}
531592
}
532593
catch (XMLStreamException e) {
533594
var logger = Logger.getLogger(LOG_GROUP);
@@ -539,6 +600,9 @@ public RssItemIterator(InputStream is) {
539600

540601
public void close() {
541602
try {
603+
if (parseWatchdog != null) {
604+
parseWatchdog.cancel(false);
605+
}
542606
reader.close();
543607
is.close();
544608
} catch (XMLStreamException | IOException e) {
@@ -783,16 +847,20 @@ private HttpClient createHttpClient() {
783847
var context = SSLContext.getInstance("TLSv1.3");
784848
context.init(null, null, null);
785849

786-
client = HttpClient.newBuilder()
850+
var builder = HttpClient.newBuilder()
787851
.sslContext(context)
788-
.connectTimeout(Duration.ofSeconds(25))
789-
.followRedirects(HttpClient.Redirect.ALWAYS)
790-
.build();
852+
.followRedirects(HttpClient.Redirect.ALWAYS);
853+
if (connectionTimeout.toMillis() > 0) {
854+
builder.connectTimeout(connectionTimeout);
855+
}
856+
client = builder.build();
791857
} catch (NoSuchAlgorithmException | KeyManagementException e) {
792-
client = HttpClient.newBuilder()
793-
.connectTimeout(Duration.ofSeconds(25))
794-
.followRedirects(HttpClient.Redirect.ALWAYS)
795-
.build();
858+
var builder = HttpClient.newBuilder()
859+
.followRedirects(HttpClient.Redirect.ALWAYS);
860+
if (connectionTimeout.toMillis() > 0) {
861+
builder.connectTimeout(connectionTimeout);
862+
}
863+
client = builder.build();
796864
}
797865

798866
return client;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package com.apptasticsoftware.integrationtest;
2+
3+
import com.apptasticsoftware.rssreader.Item;
4+
import com.apptasticsoftware.rssreader.RssReader;
5+
import com.apptasticsoftware.rssreader.util.RssServer;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.io.File;
9+
import java.io.IOException;
10+
import java.time.Duration;
11+
import java.time.ZonedDateTime;
12+
import java.util.List;
13+
import java.util.stream.Collectors;
14+
15+
import static org.junit.jupiter.api.Assertions.*;
16+
17+
class ConnectionTest {
18+
private static final int PORT = 8008;
19+
private static final Duration NEGATIVE_DURATION = Duration.ofSeconds(-30);
20+
21+
@Test
22+
void testConnectionTimeoutWithNullValue() {
23+
var rssReader = new RssReader();
24+
var exception = assertThrows(NullPointerException.class, () -> rssReader.setConnectionTimeout(null));
25+
assertEquals("Connection timeout must not be null", exception.getMessage());
26+
}
27+
28+
@Test
29+
void testRequestTimeoutWithNullValue() {
30+
var rssReader = new RssReader();
31+
var exception = assertThrows(NullPointerException.class, () -> rssReader.setRequestTimeout(null));
32+
assertEquals("Request timeout must not be null", exception.getMessage());
33+
}
34+
35+
@Test
36+
void testReadTimeoutWithNullValue() {
37+
var rssReader = new RssReader();
38+
var exception = assertThrows(NullPointerException.class, () -> rssReader.setReadTimeout(null));
39+
assertEquals("Read timeout must not be null", exception.getMessage());
40+
}
41+
42+
@Test
43+
void testConnectionTimeoutWithNegativeValue() {
44+
var rssReader = new RssReader();
45+
var exception = assertThrows(IllegalArgumentException.class, () -> rssReader.setConnectionTimeout(NEGATIVE_DURATION));
46+
assertEquals("Connection timeout must not be negative", exception.getMessage());
47+
}
48+
49+
@Test
50+
void testRequestTimeoutWithNegativeValue() {
51+
var rssReader = new RssReader();
52+
var exception = assertThrows(IllegalArgumentException.class, () -> rssReader.setRequestTimeout(NEGATIVE_DURATION));
53+
assertEquals("Request timeout must not be negative", exception.getMessage());
54+
}
55+
56+
@Test
57+
void testReadTimeoutWithNegativeValue() {
58+
var rssReader = new RssReader();
59+
var exception = assertThrows(IllegalArgumentException.class, () -> rssReader.setReadTimeout(NEGATIVE_DURATION));
60+
assertEquals("Read timeout must not be negative", exception.getMessage());
61+
}
62+
63+
@Test
64+
void testReadFromLocalRssServerNoTimeout() throws IOException {
65+
var server = RssServer.with(getFile("atom-feed.xml"))
66+
.port(PORT)
67+
.endpointPath("/rss")
68+
.build();
69+
server.start();
70+
71+
var items = new RssReader()
72+
.setConnectionTimeout(Duration.ZERO)
73+
.setRequestTimeout(Duration.ZERO)
74+
.setReadTimeout(Duration.ZERO)
75+
.read("http://localhost:8008/rss")
76+
.collect(Collectors.toList());
77+
78+
server.stop();
79+
verify(3, items);
80+
}
81+
82+
@Test
83+
void testReadFromLocalRssServer10SecondTimeout() throws IOException {
84+
var server = RssServer.with(getFile("atom-feed.xml"))
85+
.port(PORT)
86+
.endpointPath("/rss")
87+
.build();
88+
server.start();
89+
90+
var items = new RssReader()
91+
.setConnectionTimeout(Duration.ofSeconds(10))
92+
.setRequestTimeout(Duration.ofSeconds(10))
93+
.setReadTimeout(Duration.ofSeconds(10))
94+
.read("http://localhost:8008/rss")
95+
.collect(Collectors.toList());
96+
97+
server.stop();
98+
verify(3, items);
99+
}
100+
101+
102+
@Test
103+
void testReadFromLocalRssServer() throws IOException {
104+
var server = RssServer.with(getFile("atom-feed.xml"))
105+
.port(PORT)
106+
.endpointPath("/rss")
107+
.build();
108+
server.start();
109+
110+
var items = new RssReader()
111+
.setReadTimeout(Duration.ofSeconds(2))
112+
.read("http://localhost:8008/rss")
113+
.collect(Collectors.toList());
114+
115+
server.stop();
116+
verify(3, items);
117+
}
118+
119+
@Test
120+
void testNoReadTimeout() throws IOException {
121+
var server = RssServer.with(getFile("atom-feed.xml"))
122+
.port(PORT)
123+
.endpointPath("/rss")
124+
.build();
125+
server.start();
126+
127+
var items = new RssReader()
128+
.setReadTimeout(Duration.ZERO)
129+
.read("http://localhost:8008/rss")
130+
.collect(Collectors.toList());
131+
132+
server.stop();
133+
verify(3, items);
134+
}
135+
136+
@Test
137+
void testReadTimeout() throws IOException {
138+
var server = RssServer.withWritePause(getFile("atom-feed.xml"), Duration.ofSeconds(4))
139+
.port(PORT)
140+
.endpointPath("/slow-server")
141+
.build();
142+
server.start();
143+
144+
var items = new RssReader()
145+
.setReadTimeout(Duration.ofSeconds(2))
146+
.read("http://localhost:8008/slow-server")
147+
.collect(Collectors.toList());
148+
149+
server.stop();
150+
verify(2, items);
151+
}
152+
153+
private static void verify(int expectedSize, List<Item> items) {
154+
assertEquals(expectedSize, items.size());
155+
156+
if (!items.isEmpty()) {
157+
assertEquals("dive into mark", items.get(0).getChannel().getTitle());
158+
assertEquals(65, items.get(0).getChannel().getDescription().length());
159+
assertEquals("http://example.org/feed.atom", items.get(0).getChannel().getLink());
160+
assertEquals("Copyright (c) 2003, Mark Pilgrim", items.get(0).getChannel().getCopyright().orElse(null));
161+
assertEquals("Example Toolkit", items.get(0).getChannel().getGenerator().orElse(null));
162+
assertEquals("2005-07-31T12:29:29Z", items.get(0).getChannel().getLastBuildDate().orElse(null));
163+
164+
assertEquals("Atom draft-07 snapshot", items.get(0).getTitle().orElse(null));
165+
assertNull(items.get(1).getAuthor().orElse(null));
166+
assertEquals("http://example.org/audio/ph34r_my_podcast.mp3", items.get(0).getLink().orElse(null));
167+
assertEquals("tag:example.org,2003:3.2397", items.get(0).getGuid().orElse(null));
168+
assertEquals("2003-12-13T08:29:29-04:00", items.get(0).getPubDate().orElse(null));
169+
assertEquals("2005-07-31T12:29:29Z", items.get(0).getUpdated().orElse(null));
170+
assertEquals(211, items.get(1).getDescription().orElse("").length());
171+
}
172+
if (items.size() >= 2) {
173+
assertEquals("Atom-Powered Robots Run Amok", items.get(1).getTitle().orElse(null));
174+
assertNull(items.get(1).getAuthor().orElse(null));
175+
assertEquals("http://example.org/2003/12/13/atom03", items.get(1).getLink().orElse(null));
176+
assertEquals("urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a", items.get(1).getGuid().orElse(null));
177+
assertEquals("2003-12-13T18:30:02Z", items.get(1).getPubDate().orElse(null));
178+
assertEquals("2003-12-13T18:30:02Z", items.get(1).getUpdated().orElse(null));
179+
assertEquals(211, items.get(1).getDescription().orElse("").length());
180+
}
181+
if (items.size() >= 3) {
182+
assertEquals("Atom-Powered Robots Run Amok 2", items.get(2).getTitle().orElse(null));
183+
assertNull(items.get(2).getAuthor().orElse(null));
184+
assertEquals("http://example.org/2003/12/13/atom04", items.get(2).getLink().orElse(null));
185+
assertEquals("urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b", items.get(2).getGuid().orElse(null));
186+
assertEquals("2003-12-13T09:28:28-04:00", items.get(2).getPubDate().orElse(null));
187+
assertEquals(1071322108, items.get(2).getPubDateZonedDateTime().map(ZonedDateTime::toEpochSecond).orElse(null));
188+
assertEquals("2003-12-13T18:30:01Z", items.get(2).getUpdated().orElse(null));
189+
assertEquals(1071340201, items.get(2).getUpdatedZonedDateTime().map(ZonedDateTime::toEpochSecond).orElse(null));
190+
assertEquals(47, items.get(2).getDescription().orElse("").length());
191+
}
192+
}
193+
194+
private File getFile(String filename) {
195+
var url = getClass().getClassLoader().getResource(filename);
196+
return new File(url.getFile());
197+
}
198+
}

0 commit comments

Comments
 (0)