Skip to content

Commit 3dd372c

Browse files
Merge pull request #737 from Netflix/type-error-handling
Ensure that one failing subscription does not prevent others from being called
2 parents 41cd26d + a3bdca9 commit 3dd372c

File tree

5 files changed

+188
-17
lines changed

5 files changed

+188
-17
lines changed

archaius2-core/src/main/java/com/netflix/archaius/DefaultPropertyFactory.java

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -224,19 +224,24 @@ public Subscription subscribe(Consumer<T> consumer) {
224224
private T current = get();
225225
@Override
226226
public synchronized void run() {
227-
T newValue = get();
228-
if (current == newValue && current == null) {
229-
return;
230-
} else if (current == null) {
231-
current = newValue;
232-
} else if (newValue == null) {
233-
current = null;
234-
} else if (current.equals(newValue)) {
235-
return;
236-
} else {
237-
current = newValue;
227+
try {
228+
T newValue = get();
229+
if (current == newValue && current == null) {
230+
return;
231+
} else if (current == null) {
232+
current = newValue;
233+
} else if (newValue == null) {
234+
current = null;
235+
} else if (current.equals(newValue)) {
236+
return;
237+
} else {
238+
current = newValue;
239+
}
240+
consumer.accept(current);
241+
} catch (RuntimeException e) {
242+
LOG.error("Unable to notify subscriber about update to property '{}'. Subscriber: {}",
243+
keyAndType, consumer, e);
238244
}
239-
consumer.accept(current);
240245
}
241246
};
242247

archaius2-core/src/main/java/com/netflix/archaius/config/AbstractConfig.java

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import com.netflix.archaius.exceptions.ParseException;
2626
import com.netflix.archaius.interpolate.CommonsStrInterpolator;
2727
import com.netflix.archaius.interpolate.ConfigStrLookup;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
2830

2931
import java.lang.reflect.Type;
3032
import java.math.BigDecimal;
@@ -38,6 +40,7 @@
3840

3941
public abstract class AbstractConfig implements Config {
4042

43+
private static final Logger log = LoggerFactory.getLogger(AbstractConfig.class);
4144
private final CopyOnWriteArrayList<ConfigListener> listeners = new CopyOnWriteArrayList<>();
4245
private final Lookup lookup;
4346
private Decoder decoder;
@@ -109,27 +112,59 @@ public void removeListener(ConfigListener listener) {
109112
listeners.remove(listener);
110113
}
111114

115+
/**
116+
* Notify all listeners that the child configuration has been updated.
117+
* @implNote This implementation calls listeners in the order they were added. This is NOT part of the API contract.
118+
* @implSpec Implementors must make sure that if a listener fails it will not prevent other listeners from being
119+
* called. In practice this means that exceptions must be caught (and probably logged), but not rethrown.
120+
*/
112121
protected void notifyConfigUpdated(Config child) {
113122
for (ConfigListener listener : listeners) {
114-
listener.onConfigUpdated(child);
123+
callSafely(() -> listener.onConfigUpdated(child));
115124
}
116125
}
117126

127+
/**
128+
* Notify all listeners that the child configuration encountered an error
129+
* @implNote This implementation calls listeners in the order they were added. This is NOT part of the API contract.
130+
* @implSpec Implementors must make sure that if a listener fails it will not prevent other listeners from being
131+
* called. In practice this means that exceptions must be caught (and probably logged), but not rethrown.
132+
*/
118133
protected void notifyError(Throwable t, Config child) {
119134
for (ConfigListener listener : listeners) {
120-
listener.onError(t, child);
135+
callSafely(() -> listener.onError(t, child));
121136
}
122137
}
123138

139+
/**
140+
* Notify all listeners that a child configuration has been added.
141+
* @implNote This implementation calls listeners in the order they were added. This is NOT part of the API contract.
142+
* @implSpec Implementors must make sure that if a listener fails it will not prevent other listeners from being
143+
* called. In practice this means that exceptions must be caught (and probably logged), but not rethrown.
144+
*/
124145
protected void notifyConfigAdded(Config child) {
125146
for (ConfigListener listener : listeners) {
126-
listener.onConfigAdded(child);
147+
callSafely(() -> listener.onConfigAdded(child));
127148
}
128149
}
129150

151+
/**
152+
* Notify all listeners that the child configuration has been removed.
153+
* @implNote This implementation calls listeners in the order they were added. This is NOT part of the API contract.
154+
* @implSpec Implementors must make sure that if a listener fails it will not prevent other listeners from being
155+
* called. In practice this means that exceptions must be caught (and probably logged), but not rethrown.
156+
*/
130157
protected void notifyConfigRemoved(Config child) {
131158
for (ConfigListener listener : listeners) {
132-
listener.onConfigRemoved(child);
159+
callSafely(() -> listener.onConfigRemoved(child));
160+
}
161+
}
162+
163+
private void callSafely(Runnable r) {
164+
try {
165+
r.run();
166+
} catch (RuntimeException t) {
167+
log.error("A listener on config {} failed when receiving a notification. listener={}", this, r, t);
133168
}
134169
}
135170

archaius2-core/src/test/java/com/netflix/archaius/config/AbstractConfigTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import java.util.Map;
2525
import java.util.function.BiConsumer;
2626

27+
import com.netflix.archaius.api.Config;
28+
import com.netflix.archaius.api.ConfigListener;
2729
import com.netflix.archaius.exceptions.ParseException;
2830
import org.junit.jupiter.api.Test;
2931

@@ -33,6 +35,8 @@
3335
import static org.junit.jupiter.api.Assertions.assertEquals;
3436
import static org.junit.jupiter.api.Assertions.assertFalse;
3537
import static org.junit.jupiter.api.Assertions.assertThrows;
38+
import static org.mockito.Mockito.mock;
39+
import static org.mockito.Mockito.verify;
3640

3741
public class AbstractConfigTest {
3842

@@ -181,4 +185,32 @@ public void testGetRawNumerics() {
181185
assertEquals(42L, config.get(Long.class, "int"));
182186
assertEquals(42L, config.get(Long.class, "byte"));
183187
}
188+
189+
@Test
190+
public void testListeners() {
191+
ConfigListener goodListener = mock(ConfigListener.class, "goodListener");
192+
ConfigListener alwaysFailsListener = mock(ConfigListener.class, invocation -> { throw new RuntimeException("This listener fails on purpose"); });
193+
ConfigListener secondGoodListener = mock(ConfigListener.class, "secondGoodListener");
194+
RuntimeException mockError = new RuntimeException("Mock error");
195+
196+
Config mockChildConfig = mock(Config.class);
197+
198+
config.addListener(alwaysFailsListener);
199+
config.addListener(goodListener);
200+
config.addListener(secondGoodListener);
201+
202+
config.notifyConfigUpdated(mockChildConfig);
203+
config.notifyConfigAdded(mockChildConfig);
204+
config.notifyConfigRemoved(mockChildConfig);
205+
config.notifyError(mockError, mockChildConfig);
206+
207+
// All 3 listeners should receive all notifications (In order, actually, but we should not verify that
208+
// because it's not part of the contract).
209+
for (ConfigListener listener : Arrays.asList(goodListener, alwaysFailsListener, secondGoodListener)) {
210+
verify(listener).onConfigUpdated(mockChildConfig);
211+
verify(listener).onConfigAdded(mockChildConfig);
212+
verify(listener).onConfigRemoved(mockChildConfig);
213+
verify(listener).onError(mockError, mockChildConfig);
214+
}
215+
}
184216
}

archaius2-core/src/test/java/com/netflix/archaius/config/MapConfigTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import static org.junit.jupiter.api.Assertions.assertThrows;
3535
import static org.junit.jupiter.api.Assertions.assertTrue;
3636

37+
/** This is ALSO a test of many of the methods in the AbstractConfig super class. */
3738
public class MapConfigTest {
3839
private final MapConfig config = MapConfig.builder()
3940
.put("str", "value")

archaius2-core/src/test/java/com/netflix/archaius/property/PropertyTest.java

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.util.concurrent.atomic.AtomicReference;
3131
import java.util.function.Consumer;
3232
import java.util.function.Function;
33+
import java.util.function.IntConsumer;
3334

3435
import com.netflix.archaius.api.PropertyContainer;
3536
import com.netflix.archaius.exceptions.ParseException;
@@ -464,7 +465,104 @@ public void chainedPropertyBadValue() {
464465
containsString("'first'"),
465466
containsString("'bad'")));
466467
}
467-
468+
469+
@Test
470+
public void testSubscriptionsBadValue() {
471+
config.setProperty("first", "2");
472+
Property<Integer> integerProperty = factory
473+
.get("first", Integer.class)
474+
.orElse(3);
475+
Property<String> stringProperty = factory.get("first", String.class);
476+
477+
Consumer<Integer> integerConsumer = unexpected -> fail("Consumer should not be called. Received argument: " + unexpected);
478+
AtomicReference<String> stringConsumer = new AtomicReference<>();
479+
480+
// Order is important here! We want the string subscription to be called *after* trying to call the integer
481+
// subscription, which should fail because "a" can not be decoded as an integer
482+
integerProperty.subscribe(integerConsumer);
483+
stringProperty.subscribe(stringConsumer::set);
484+
485+
// Initial value for the property is 2
486+
assertEquals(2, integerProperty.get().intValue());
487+
assertEquals("2", stringProperty.get());
488+
489+
// Set it to something that's not an integer
490+
config.setProperty("first", "a");
491+
492+
// The integer consumer is never called
493+
// The string consumer *is* called with the new value
494+
assertEquals("a", stringConsumer.get());
495+
}
496+
497+
@Test
498+
public void testSubscriptionsNullValue() {
499+
config.setProperty("first", "2");
500+
Property<Integer> integerProperty = factory.get("first", Integer.class);
501+
Property<Integer> intWithDefaultProperty = factory.get("first", Integer.class).orElse(3);
502+
Property<String> stringProperty = factory.get("first", String.class);
503+
504+
IntConsumer primitiveIntConsumer = unexpected -> fail("Consumer should not be called. Received argument: " + unexpected);
505+
AtomicInteger primitiveIntConsumerForPropertyWithDefault = new AtomicInteger(1000);
506+
AtomicReference<Integer> boxedIntConsumer = new AtomicReference<>(1000);
507+
AtomicReference<String> stringConsumer = new AtomicReference<>("initial");
508+
509+
// Order is important here! We want the string subscription to be called *after* trying to call the integer
510+
// subscription, which should fail because null can't be cast to a primitive int.
511+
integerProperty.subscribe(primitiveIntConsumer::accept);
512+
intWithDefaultProperty.subscribe(primitiveIntConsumerForPropertyWithDefault::set);
513+
integerProperty.subscribe(boxedIntConsumer::set);
514+
stringProperty.subscribe(stringConsumer::set);
515+
516+
// Initial value for the property is 2
517+
assertEquals(2, integerProperty.get().intValue());
518+
assertEquals("2", stringProperty.get());
519+
520+
// Set it to something that's not an integer
521+
config.setProperty("first", null);
522+
523+
// The integer consumer is never called
524+
// The string consumer *is* called with the new value
525+
assertNull(stringConsumer.get());
526+
// ... the boxed integer consumer also gets the new value
527+
assertNull(boxedIntConsumer.get());
528+
// ... finally, the consumer for the property with a default is also called and receives the default value
529+
assertEquals(3, primitiveIntConsumerForPropertyWithDefault.get());
530+
}
531+
532+
@Test
533+
public void testSubscriptionsConsumerFails() {
534+
config.setProperty("first", "2");
535+
Property<Integer> integerProperty = factory
536+
.get("first", Integer.class)
537+
.orElse(3);
538+
Property<String> stringProperty = factory.get("first", String.class);
539+
540+
AtomicReference<Integer> integerConsumerArgument = new AtomicReference<>();
541+
Consumer<Integer> faultyConsumer = value -> {
542+
integerConsumerArgument.set(value); // Save argument for later verification
543+
throw new RuntimeException("I'm a bad consumer");
544+
};
545+
AtomicReference<String> stringConsumer = new AtomicReference<>();
546+
547+
// Order is important here! We want the string subscription to be called *after* trying to call the integer
548+
// subscription, which should fail because "a" can not be decoded as an integer
549+
integerProperty.subscribe(faultyConsumer);
550+
stringProperty.subscribe(stringConsumer::set);
551+
552+
// Initial value for the property is 2
553+
assertEquals(2, integerProperty.get().intValue());
554+
assertEquals("2", stringProperty.get());
555+
556+
// Set it to something that's not an integer
557+
config.setProperty("first", "5");
558+
559+
// The faulty consumer was called
560+
assertEquals(5, integerConsumerArgument.get().intValue());
561+
// The string consumer also was called.
562+
assertEquals("5", stringConsumer.get());
563+
}
564+
565+
468566
@Test
469567
public void testCache() {
470568
config.setProperty("foo", "1");

0 commit comments

Comments
 (0)