Skip to content

Commit f7100e1

Browse files
hnc-jgleeclaude
authored andcommitted
fix(hybrid): activate --hybrid-fallback on server-absent path (PDFDLOSP-21)
Phase 0 health check in HybridDocumentProcessor invoked checkAvailability() outside the fallback try/catch, so a connection refused IOException propagated past every isFallbackToJava() check and the option was a no-op in the server-absent path. Wrap the call and route through a new processAllPagesAsJavaFallback helper when fallback is enabled. Hint --hybrid-fallback in the unavailability message and add regression coverage for fallback ON/OFF/timeout combos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2e585ac commit f7100e1

5 files changed

Lines changed: 203 additions & 1 deletion

File tree

java/opendataloader-pdf-cli/src/test/java/org/opendataloader/pdf/cli/CLIMainTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,47 @@ private static Path createPasswordProtectedPdf(Path target, String password) thr
367367
return target;
368368
}
369369

370+
// --- PDFDLOSP-21: hybrid backend unavailable, stack-trace guard -------
371+
372+
/**
373+
* When the hybrid backend is unreachable and {@code --hybrid-fallback} is
374+
* NOT supplied, CLIMain must surface the friendly fail-fast message on
375+
* stdout (including the new {@code --hybrid-fallback} hint) and must never
376+
* leak a Java stack trace. Regression for PDFDLOSP-21.
377+
*/
378+
@Test
379+
void testHybridUnavailableWithoutFallbackEmitsFriendlyMessageNoStackTrace() throws IOException {
380+
Path testPdf = tempDir.resolve("ok.pdf");
381+
// Minimal but valid-enough header so the file reaches HybridDocumentProcessor.
382+
// PDF parsing happens after health-check on this branch.
383+
try (PDDocument doc = new PDDocument()) {
384+
doc.addPage(new PDPage());
385+
doc.save(testPdf.toFile());
386+
}
387+
388+
// Reserve an ephemeral port and release it so connect() is refused.
389+
int closedPort;
390+
try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
391+
closedPort = socket.getLocalPort();
392+
}
393+
394+
String[] stdoutHolder = new String[1];
395+
int exitCode = (int) runCapturingStdout(() -> CLIMain.run(new String[]{
396+
"--hybrid", "docling-fast",
397+
"--hybrid-mode", "full",
398+
"--hybrid-url", "http://127.0.0.1:" + closedPort,
399+
"--output", tempDir.toString(),
400+
testPdf.toString()
401+
}), stdoutHolder);
402+
403+
assertNotEquals(0, exitCode,
404+
"Exit code must be non-zero when hybrid backend is unreachable and fallback is off");
405+
406+
String out = stdoutHolder[0];
407+
assertFalse(out.contains("\tat "),
408+
"Output must not contain a Java stack trace ('\\tat '); got: " + out);
409+
}
410+
370411
// --- PDFDLOSP-14: magic-number guard regressions ----------------------
371412

372413
private static final byte[] JPEG_PREFIX = new byte[]{

java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/hybrid/DoclingFastServerClient.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ public void checkAvailability() throws IOException {
117117
+ " 1. Install: pip install \"opendataloader-pdf[hybrid]\"\n"
118118
+ " 2. Start: opendataloader-pdf-hybrid --port 5002\n"
119119
+ "To use a remote server or custom port: --hybrid-url http://host:port\n"
120+
+ "Or pass --hybrid-fallback to fall back to Java-only output for this run.\n"
120121
+ "Or run without --hybrid flag for Java-only processing.", e);
121122
}
122123
try (response) {

java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/hybrid/HancomClient.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ public void checkAvailability() throws IOException {
121121
throw new IOException(
122122
"Hybrid server is not available at " + baseUrl + "\n"
123123
+ "Please check the server URL and ensure the Hancom API is accessible.\n"
124+
+ "Or pass --hybrid-fallback to fall back to Java-only output for this run.\n"
124125
+ "Or run without --hybrid flag for Java-only processing.", e);
125126
}
126127
}

java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/processors/HybridDocumentProcessor.java

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,20 @@ public static List<List<IObject>> processDocument(
184184
// Phase 0: Check backend availability before any processing.
185185
// Runs before triage intentionally — if the user explicitly requested hybrid mode,
186186
// they expect the server to be available regardless of how pages would be routed.
187-
getClient(config).checkAvailability();
187+
// When the health check fails and --hybrid-fallback is enabled, route every page
188+
// through the Java path instead of aborting the whole run (PDFDLOSP-21).
189+
try {
190+
getClient(config).checkAvailability();
191+
} catch (IOException e) {
192+
if (config.getHybridConfig().isFallbackToJava()) {
193+
LOGGER.log(Level.WARNING,
194+
"Hybrid backend unavailable; falling back to Java-only processing: {0}",
195+
e.getMessage());
196+
return processAllPagesAsJavaFallback(
197+
inputPdfName, config, pagesToProcess, totalPages);
198+
}
199+
throw e;
200+
}
188201

189202
// Phase 1: Filter all pages and collect filtered contents
190203
Map<Integer, List<IObject>> filteredContents = filterAllPages(inputPdfName, config, pagesToProcess, totalPages);
@@ -279,6 +292,40 @@ public static List<List<IObject>> processDocument(
279292
return contents;
280293
}
281294

295+
/**
296+
* Runs the full document through the Java path when the hybrid backend health check
297+
* fails and {@code --hybrid-fallback} is enabled. Mirrors the structure of
298+
* {@link #processDocument} so the caller still gets a per-page result list, but
299+
* skips triage and the backend chunk loop entirely.
300+
*/
301+
private static List<List<IObject>> processAllPagesAsJavaFallback(
302+
String inputPdfName,
303+
Config config,
304+
Set<Integer> pagesToProcess,
305+
int totalPages) throws IOException {
306+
307+
Map<Integer, List<IObject>> filteredContents =
308+
filterAllPages(inputPdfName, config, pagesToProcess, totalPages);
309+
310+
Set<Integer> allPages = new HashSet<>();
311+
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
312+
if (shouldProcessPage(pageNumber, pagesToProcess)) {
313+
allPages.add(pageNumber);
314+
}
315+
}
316+
317+
Map<Integer, List<IObject>> javaResults =
318+
processJavaPath(filteredContents, allPages, config, totalPages);
319+
320+
List<List<IObject>> contents = new ArrayList<>();
321+
for (int i = 0; i < totalPages; i++) {
322+
contents.add(new ArrayList<>());
323+
}
324+
mergeResults(contents, javaResults, new HashMap<>(), pagesToProcess, totalPages);
325+
postProcess(contents, config, pagesToProcess, totalPages);
326+
return contents;
327+
}
328+
282329
private static List<List<IObject>> createEmptyContents(int totalPages) {
283330
List<List<IObject>> contents = new ArrayList<>(totalPages);
284331
for (int i = 0; i < totalPages; i++) {

java/opendataloader-pdf-core/src/test/java/org/opendataloader/pdf/HybridBackendFailureIntegrationTest.java

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@
2929

3030
import java.io.File;
3131
import java.io.IOException;
32+
import java.net.ServerSocket;
33+
import java.nio.file.Files;
3234
import java.nio.file.Path;
3335

36+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
3437
import static org.junit.jupiter.api.Assertions.assertFalse;
3538
import static org.junit.jupiter.api.Assertions.assertThrows;
3639
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -122,4 +125,113 @@ void backendPartialSuccessFailsFastWhenFallbackDisabled() {
122125
assertTrue(ex.getMessage().contains("page(s) with fallback disabled"),
123126
"exception should mention fallback context: " + ex.getMessage());
124127
}
128+
129+
/**
130+
* PDFDLOSP-21 case 1 (P0): backend is entirely unreachable and
131+
* {@code --hybrid-fallback} is ON. {@code processDocument} must catch the
132+
* health-check IOException and route every page through the Java path so the
133+
* run completes normally and writes a valid JSON output.
134+
*/
135+
@Test
136+
void serverAbsent_withFallback_processesWithJava() throws IOException {
137+
// Reserve a port and immediately close the socket so connection attempts
138+
// hit a closed port (most reliable connection-refused simulation that
139+
// does not depend on platform DNS quirks).
140+
int closedPort = reserveClosedPort();
141+
String unreachableUrl = "http://127.0.0.1:" + closedPort;
142+
143+
Config config = new Config();
144+
config.setOutputFolder(tempDir.toString());
145+
config.setGenerateJSON(true);
146+
config.setHybrid("docling-fast");
147+
config.getHybridConfig().setMode(HybridConfig.MODE_FULL);
148+
config.getHybridConfig().setUrl(unreachableUrl);
149+
config.getHybridConfig().setFallbackToJava(true);
150+
151+
assertDoesNotThrow(
152+
() -> DocumentProcessor.processFile(samplePdf.getAbsolutePath(), config),
153+
"with --hybrid-fallback the run must succeed when the backend is absent");
154+
155+
Path jsonOutput = tempDir.resolve("lorem.json");
156+
assertTrue(Files.exists(jsonOutput),
157+
"Java-fallback path should still produce JSON output at " + jsonOutput);
158+
assertTrue(Files.size(jsonOutput) > 0L,
159+
"JSON output must not be empty when fallback ran successfully");
160+
}
161+
162+
/**
163+
* PDFDLOSP-21 case 2 (P0): backend is entirely unreachable and
164+
* {@code --hybrid-fallback} is OFF. The run must fail fast with an
165+
* {@link IOException} whose message points the user at the new
166+
* {@code --hybrid-fallback} escape hatch (DoclingFastServerClient patch).
167+
*/
168+
@Test
169+
void serverAbsent_withoutFallback_failsFastWithHelpfulMessage() throws IOException {
170+
int closedPort = reserveClosedPort();
171+
String unreachableUrl = "http://127.0.0.1:" + closedPort;
172+
173+
Config config = new Config();
174+
config.setOutputFolder(tempDir.toString());
175+
config.setGenerateJSON(true);
176+
config.setHybrid("docling-fast");
177+
config.getHybridConfig().setMode(HybridConfig.MODE_FULL);
178+
config.getHybridConfig().setUrl(unreachableUrl);
179+
// Document the precondition: fallback stays off, so health-check failure
180+
// must propagate.
181+
assertFalse(config.getHybridConfig().isFallbackToJava(),
182+
"fallback must be disabled for the fail-fast scenario");
183+
184+
IOException ex = assertThrows(IOException.class,
185+
() -> DocumentProcessor.processFile(samplePdf.getAbsolutePath(), config));
186+
187+
String msg = ex.getMessage() == null ? "" : ex.getMessage();
188+
assertTrue(msg.contains("Hybrid server is not available"),
189+
"exception should identify the health-check failure: " + msg);
190+
assertTrue(msg.contains("--hybrid-fallback"),
191+
"exception should point user to --hybrid-fallback escape hatch: " + msg);
192+
}
193+
194+
/**
195+
* PDFDLOSP-21 case 3 (P0): same as case 1 but with an aggressive 1 ms
196+
* timeout. The fallback path must be triggered by any IOException raised
197+
* from {@code checkAvailability()} — connection refused, connect timeout,
198+
* read timeout — so this guards H-6/H-7 equivalence: timeout-driven failures
199+
* are recovered just like connection-refused failures.
200+
*/
201+
@Test
202+
void serverAbsent_withFallbackAndTinyTimeout_processesWithJava() throws IOException {
203+
int closedPort = reserveClosedPort();
204+
String unreachableUrl = "http://127.0.0.1:" + closedPort;
205+
206+
Config config = new Config();
207+
config.setOutputFolder(tempDir.toString());
208+
config.setGenerateJSON(true);
209+
config.setHybrid("docling-fast");
210+
config.getHybridConfig().setMode(HybridConfig.MODE_FULL);
211+
config.getHybridConfig().setUrl(unreachableUrl);
212+
config.getHybridConfig().setFallbackToJava(true);
213+
config.getHybridConfig().setTimeoutMs(1);
214+
215+
assertDoesNotThrow(
216+
() -> DocumentProcessor.processFile(samplePdf.getAbsolutePath(), config),
217+
"fallback must handle timeout-driven health-check failures too");
218+
219+
Path jsonOutput = tempDir.resolve("lorem.json");
220+
assertTrue(Files.exists(jsonOutput),
221+
"Java-fallback path should still produce JSON output at " + jsonOutput);
222+
assertTrue(Files.size(jsonOutput) > 0L,
223+
"JSON output must not be empty when fallback ran with tiny timeout");
224+
}
225+
226+
/**
227+
* Binds an ephemeral port and releases it. Subsequent connect() attempts to
228+
* the returned port number are extremely likely to be refused, giving a
229+
* deterministic "backend unreachable" condition without relying on
230+
* unrouteable IPs (which can hang on some hosts).
231+
*/
232+
private static int reserveClosedPort() throws IOException {
233+
try (ServerSocket socket = new ServerSocket(0)) {
234+
return socket.getLocalPort();
235+
}
236+
}
125237
}

0 commit comments

Comments
 (0)