Skip to content

Commit d412139

Browse files
committed
Gracefully handle parse failure with locking
Before we would poison the map with the lock object so the second caller would get a weird ClassCastException.
1 parent 8f5a3c9 commit d412139

File tree

3 files changed

+129
-28
lines changed

3 files changed

+129
-28
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
**Fixed**
1414

15-
- Nothing yet!
15+
- Ensure that exceptions thrown from failure to parse method annotations can be observed by multiple threads/callers. Previously only the first caller would see the actual parsing exception and other callers would get a cryptic `ClassCastException`.
1616

1717

1818
## [2.10.0] - 2024-03-18

retrofit/java-test/src/test/java/retrofit2/RetrofitTest.java

+86
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import static org.junit.Assert.assertEquals;
2222
import static org.junit.Assert.assertNotNull;
2323
import static org.junit.Assert.assertSame;
24+
import static org.junit.Assert.assertThrows;
2425
import static org.junit.Assert.assertTrue;
2526
import static org.junit.Assert.fail;
2627
import static retrofit2.AnnotationArraySubject.assertThat;
@@ -1716,4 +1717,89 @@ public void argumentCapture() throws Exception {
17161717

17171718
assertEquals("/?i=201", server.takeRequest().getPath());
17181719
}
1720+
1721+
@Test
1722+
public void annotationParsingFailureObservedByWaitingThreads() throws InterruptedException {
1723+
AtomicInteger fails = new AtomicInteger();
1724+
CountDownLatch startedParsing = new CountDownLatch(1);
1725+
CountDownLatch failParsing = new CountDownLatch(1);
1726+
RuntimeException failure = new RuntimeException("boom!");
1727+
Retrofit retrofit =
1728+
new Retrofit.Builder()
1729+
.baseUrl(server.url("/"))
1730+
.addConverterFactory(
1731+
new Converter.Factory() {
1732+
@Nullable
1733+
@Override
1734+
public Converter<ResponseBody, ?> responseBodyConverter(
1735+
Type type, Annotation[] annotations, Retrofit retrofit) {
1736+
startedParsing.countDown();
1737+
try {
1738+
failParsing.await();
1739+
} catch (InterruptedException e) {
1740+
throw new AssertionError(e);
1741+
}
1742+
fails.incrementAndGet();
1743+
throw failure;
1744+
}
1745+
})
1746+
.build();
1747+
Annotated service = retrofit.create(Annotated.class);
1748+
1749+
AtomicReference<RuntimeException> result1 = new AtomicReference<>();
1750+
Thread thread1 =
1751+
new Thread(
1752+
() -> {
1753+
try {
1754+
service.method();
1755+
} catch (RuntimeException e) {
1756+
result1.set(e);
1757+
}
1758+
});
1759+
thread1.start();
1760+
1761+
// Wait for thread1 to enter the converter. This means it has inserted and taken a lock on
1762+
// parsing for the method.
1763+
startedParsing.await();
1764+
1765+
CountDownLatch thread2Locked = new CountDownLatch(1);
1766+
AtomicReference<RuntimeException> result2 = new AtomicReference<>();
1767+
Thread thread2 =
1768+
new Thread(
1769+
() -> {
1770+
try {
1771+
thread2Locked.countDown();
1772+
service.method();
1773+
} catch (RuntimeException e) {
1774+
result2.set(e);
1775+
}
1776+
});
1777+
thread2.start();
1778+
thread2Locked.await();
1779+
1780+
// Wait for thread2 to lock on the shared object. This should be pretty fast after the last
1781+
// signal, but we have no way of knowing for sure it happened.
1782+
Thread.sleep(1_000);
1783+
1784+
failParsing.countDown();
1785+
thread1.join();
1786+
thread2.join();
1787+
1788+
RuntimeException failure1 = result1.get();
1789+
assertThat(failure1).isInstanceOf(IllegalArgumentException.class);
1790+
assertThat(failure1).hasCauseThat().isSameInstanceAs(failure);
1791+
1792+
RuntimeException failure2 = result2.get();
1793+
assertThat(failure2).isInstanceOf(IllegalArgumentException.class);
1794+
assertThat(failure2).hasCauseThat().isSameInstanceAs(failure);
1795+
1796+
// Importantly, even though the second thread was locked waiting on the first, failure of the
1797+
// first thread caused the second thread to retry parsing.
1798+
assertThat(fails.get()).isEqualTo(2);
1799+
1800+
// Make sure now that both threads have released the lock, new callers also retry.
1801+
RuntimeException failure3 = assertThrows(IllegalArgumentException.class, service::method);
1802+
assertThat(failure3).hasCauseThat().isSameInstanceAs(failure);
1803+
assertThat(fails.get()).isEqualTo(3);
1804+
}
17191805
}

retrofit/src/main/java/retrofit2/Retrofit.java

+42-27
Original file line numberDiff line numberDiff line change
@@ -212,38 +212,53 @@ private void validateServiceInterface(Class<?> service) {
212212
}
213213

214214
ServiceMethod<?> loadServiceMethod(Class<?> service, Method method) {
215-
// Note: Once we are minSdk 24 this whole method can be replaced by computeIfAbsent.
216-
Object lookup = serviceMethodCache.get(method);
215+
while (true) {
216+
// Note: Once we are minSdk 24 this whole method can be replaced by computeIfAbsent.
217+
Object lookup = serviceMethodCache.get(method);
217218

218-
if (lookup instanceof ServiceMethod<?>) {
219-
// Happy path: method is already parsed into the model.
220-
return (ServiceMethod<?>) lookup;
221-
}
219+
if (lookup instanceof ServiceMethod<?>) {
220+
// Happy path: method is already parsed into the model.
221+
return (ServiceMethod<?>) lookup;
222+
}
222223

223-
if (lookup == null) {
224-
// Map does not contain any value. Try to put in a lock for this method. We MUST synchronize
225-
// on the lock before it is visible to others via the map to signal we are doing the work.
226-
Object lock = new Object();
227-
synchronized (lock) {
228-
lookup = serviceMethodCache.putIfAbsent(method, lock);
229-
if (lookup == null) {
230-
// On successful lock insertion, perform the work and update the map before releasing.
231-
// Other threads may be waiting on lock now and will expect the parsed model.
232-
ServiceMethod<Object> result = ServiceMethod.parseAnnotations(this, service, method);
233-
serviceMethodCache.put(method, result);
234-
return result;
224+
if (lookup == null) {
225+
// Map does not contain any value. Try to put in a lock for this method. We MUST synchronize
226+
// on the lock before it is visible to others via the map to signal we are doing the work.
227+
Object lock = new Object();
228+
synchronized (lock) {
229+
lookup = serviceMethodCache.putIfAbsent(method, lock);
230+
if (lookup == null) {
231+
// On successful lock insertion, perform the work and update the map before releasing.
232+
// Other threads may be waiting on lock now and will expect the parsed model.
233+
ServiceMethod<Object> result;
234+
try {
235+
result = ServiceMethod.parseAnnotations(this, service, method);
236+
} catch (Throwable e) {
237+
// Remove the lock on failure. Any other locked threads will retry as a result.
238+
serviceMethodCache.remove(method);
239+
throw e;
240+
}
241+
serviceMethodCache.put(method, result);
242+
return result;
243+
}
235244
}
236245
}
237-
}
238246

239-
// Either the initial lookup or the attempt to put our lock in the map has returned someone
240-
// else's lock. This means they are doing the parsing, and will update the map before releasing
241-
// the lock. Once we can take the lock, the map is guaranteed to contain the model.
242-
// Note: There's a chance that our effort to put a lock into the map has actually returned a
243-
// finished model instead of a lock. In that case this code will perform a pointless lock and
244-
// redundant lookup in the map of the same instance. This is rare, and ultimately harmless.
245-
synchronized (lookup) {
246-
return (ServiceMethod<?>) serviceMethodCache.get(method);
247+
// Either the initial lookup or the attempt to put our lock in the map has returned someone
248+
// else's lock. This means they are doing the parsing, and will update the map before
249+
// releasing
250+
// the lock. Once we can take the lock, the map is guaranteed to contain the model or null.
251+
// Note: There's a chance that our effort to put a lock into the map has actually returned a
252+
// finished model instead of a lock. In that case this code will perform a pointless lock and
253+
// redundant lookup in the map of the same instance. This is rare, and ultimately harmless.
254+
synchronized (lookup) {
255+
Object result = serviceMethodCache.get(method);
256+
if (result == null) {
257+
// The other thread failed its parsing. We will retry (and probably also fail).
258+
continue;
259+
}
260+
return (ServiceMethod<?>) result;
261+
}
247262
}
248263
}
249264

0 commit comments

Comments
 (0)