{"record":{"id":"ac1309fe13f4de50","repo":"opendataloader-project/opendataloader-pdf","slug":"failed-to-convert-ac1309","errorCode":null,"errorMessage":"Failed to convert","messagePattern":"Failed to convert","errorType":"exception","errorClass":"IllegalStateException","httpStatus":null,"severity":"error","filePath":"java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/hybrid/HancomClient.java","lineNumber":156,"sourceCode":"            JsonNode visualInfo = getVisualInfo(fileId);\n            LOGGER.log(Level.FINE, \"Retrieved visual info for file: {0}\", fileId);\n\n            return new HybridResponse(null, visualInfo, null);\n        } finally {\n            // Step 3: Always cleanup\n            if (fileId != null) {\n                deleteFile(fileId);\n            }\n        }\n    }\n\n    @Override\n    public CompletableFuture<HybridResponse> convertAsync(HybridRequest request) {\n        return CompletableFuture.supplyAsync(() -> {\n            try {\n                return convert(request);\n            } catch (IOException e) {\n                throw new IllegalStateException(\"Failed to convert\", e);\n            }\n        });\n    }\n\n    /**\n     * Gets the base URL of this client.\n     *\n     * @return The base URL.\n     */\n    public String getBaseUrl() {\n        return baseUrl;\n    }\n\n    /**\n     * Uploads a PDF file to the Hancom API.\n     *\n     * @param pdfBytes The PDF file bytes.\n     * @return The file ID assigned by the server.","sourceCodeStart":138,"sourceCodeEnd":174,"githubUrl":"https://github.com/opendataloader-project/opendataloader-pdf/blob/a7789b8e77dd05e2b8659eb3ea12fc458f80bfb8/java/opendataloader-pdf-core/src/main/java/org/opendataloader/pdf/hybrid/HancomClient.java#L138-L174","documentation":"Inside convertAsync(), the checked IOException from the synchronous convert() method is caught and wrapped in an unchecked IllegalStateException with the message 'Failed to convert'. This is a deliberate bridge: CompletableFuture.supplyAsync takes a Supplier (which cannot throw checked exceptions), so the IOException must be hidden. Callers that .join() or .get() the future will receive a java.util.concurrent.CompletionException whose cause is this IllegalStateException whose cause is the original IOException.","triggerScenarios":"Calling HancomClient.convertAsync(request) and then .join(), .get(), or passing the future to a downstream stage that triggers execution. Any IOException during upload, visualinfo retrieval, or cleanup (deleteFile) will surface as this wrapping exception. Specifically: upload HTTP failure (error 63), empty upload body (error 64), malformed JSON (errors 65-66), visualinfo failure (errors 67-68), or network timeout.","commonSituations":"Using the async API in a parallel pipeline (e.g., processing multiple pages concurrently with ForkJoinPool) and not unwrapping the CompletionException; the original IOException's diagnostic message (e.g., 'Hancom upload failed with status 500: ...') is buried two levels deep in the cause chain and easy to miss.","solutions":["When calling .join() on the returned CompletableFuture, catch CompletionException and unwrap: `catch (CompletionException e) { Throwable cause = e.getCause(); if (cause instanceof IllegalStateException) cause = cause.getCause(); /* now the original IOException */ }`.","Switch to the synchronous convert() method directly if you don't need async — it throws IOException directly with the original diagnostic message.","Use .handle() or .exceptionally() on the future to intercept and unwrap the exception before it propagates.","Check the root cause's message (two levels down) for the real backend error details."],"exampleFix":"// before: CompletionException with buried cause\nHybridResponse resp = client.convertAsync(request).join();\n\n// after: unwrap to original IOException\nHybridResponse resp;\ntry {\n    resp = client.convertAsync(request).join();\n} catch (CompletionException e) {\n    Throwable cause = e.getCause();\n    if (cause instanceof IllegalStateException) {\n        cause = cause.getCause(); // original IOException\n    }\n    throw new IOException(\"Async conversion failed: \" + cause.getMessage(), cause);\n}","handlingStrategy":"try-catch","validationCode":"// Prefer the synchronous API when possible to avoid exception wrapping\n// If async is needed, validate inputs before calling convertAsync:\nif (request.getPdfBytes() == null || request.getPdfBytes().length == 0) {\n    throw new IllegalArgumentException(\"PDF bytes cannot be null or empty\");\n}","typeGuard":null,"tryCatchPattern":"try {\n    HybridResponse resp = client.convertAsync(request).join();\n} catch (CompletionException e) {\n    Throwable cause = e.getCause();\n    if (cause instanceof IllegalStateException) {\n        cause = cause.getCause(); // unwrap to original IOException\n    }\n    if (cause instanceof IOException) {\n        // handle the real backend error\n        LOGGER.severe(\"Conversion failed: \" + cause.getMessage());\n    }\n}","preventionTips":["Always unwrap CompletionException → IllegalStateException → IOException when handling async failures.","Prefer the synchronous convert() method when you don't need parallelism — it throws IOException directly.","Use .exceptionally() on the CompletableFuture to centralize error handling.","Log the root cause message, not just the wrapper's 'Failed to convert'."],"tags":["async","exception-wrapping","hancom","hybrid","completable-future"],"backgroundTag":null,"analyzedSha":"a7789b8e77dd05e2b8659eb3ea12fc458f80bfb8","analyzedAt":"2026-08-14T05:22:03.953Z","schemaVersion":2},"datasetVersion":"2026-08-14T10:17:34.591Z"}