Class ExternalFailures

java.lang.Object
org.elasticsearch.xpack.esql.datasources.ExternalFailures

public final class ExternalFailures extends Object
Classifies a failure raised while reading an external data source into the exception the AsyncExternalSourceOperator should surface, so that it maps to the right HTTP status. The companion surface(java.lang.Throwable, java.lang.String) helper is used at the worker rethrow sites inside parallel coordinators and page iterators to pre-type the failure: it wraps a raw IOException in an already-classified ExternalClientException (400) so the read boundary's classify(java.lang.Throwable) sees a status-typed exception. surface cannot rescue a status signal already buried under a status-neutral RuntimeException wrapper; callers must therefore pass the raw stored throwable, not a pre-wrapped one.

classify(java.lang.Throwable) is the single boundary where external-source reads turn into a user-visible error, and it is reached only for external-source queries (the operator exists only for them), so index queries are unaffected. It runs co-located with the throw, on the node that reads the external source, before the failure is serialized back to the coordinator — so classification relies on the concrete exception type while it is still available, and only the resulting status() needs to cross the wire (see ExternalException). The policy:

  • Error (assertion failures, OOM, …) is rethrown — a JVM/programming fault must stay fatal, never be downgraded to a request error.
  • An ElasticsearchException already carries its own status and is returned unchanged: this covers the ExternalException family (400/500/503) raised at the reader/storage boundary, as well as CircuitBreakingException (429) and TaskCancelledException (400).
  • An EsRejectedExecutionException — a thread pool refusing the task (e.g. the node shutting down) — is client-actionable backpressure, not a server fault. It already maps to 429 (TOO_MANY_REQUESTS) via ExceptionsHelper.status, so it is returned unchanged rather than mistaken for a broken invariant and reported as 500.
  • An IllegalArgumentException already maps to 400; it is returned as-is.
  • An IOException/UncheckedIOException, or one of the specific third-party decoding exceptions in MALFORMED_DATA_EXCEPTIONS, means we could not read or interpret the resource — a client-class ExternalClientException (400). Retryable transport failures never reach here as plain I/O errors: the storage layer raises them as ExternalException (503) first.
  • Anything else (IllegalStateException, NullPointerException, an unrecognized RuntimeException) indicates a broken invariant in our own code rather than bad input, so it surfaces as an ExternalServerException (500) to keep the bug visible.
Cancellation is not special-cased here: it arrives as a TaskCancelledException (handled by the ElasticsearchException branch, 400), and a read interrupted while blocking surfaces as an IOException subclass (so, 400). A bare InterruptedException would fall through to 500; the interrupt flag is intentionally left untouched, since this runs on the thread surfacing the stored failure, not the worker thread that was interrupted.
  • Method Details

    • classify

      public static RuntimeException classify(Throwable t)
      Returns the RuntimeException to throw for the given read failure. May instead throw if t is an Error, which must propagate unchanged.
    • surface

      public static RuntimeException surface(Throwable failure, String fallbackMessage)
      Surfaces a worker-side stored failure as a typed ES|QL exception that already carries the right HTTP status, instead of a status-neutral RuntimeException wrapper. Called at the throw site inside parallel parsing coordinators and page iterators (the boundary between the worker that stored the failure and the consumer pulling from the iterator). Companion to classify(java.lang.Throwable): where classify runs at the read boundary and maps a freshly raised failure to a status-typed exception, surface runs at the worker rethrow site and pins the status carried by the underlying type while preserving a context prefix (fallbackMessage, e.g. "Streaming parallel parsing failed") so the coordinator/iterator origin stays visible in logs and the user-facing message:
      • An Error is rethrown unchanged — a JVM/programming fault must stay fatal.
      • A RuntimeException is returned as-is. This covers status carriers (ElasticsearchException family, IllegalArgumentException) which already pin their own status, and any other unchecked cause raised by the worker. Note: a bare RuntimeException that buries an IOException cause is not rescued here — the wrapper has already destroyed the type signal. Callers must therefore pass the raw stored throwable, not a pre-wrapped one.
      • An IOException or UncheckedIOException becomes an ExternalClientException (400) — undecodable input is a client-class error, not a server fault. The fallbackMessage prefix is kept either way, so the context survives whether the worker raised checked or unchecked I/O.
      • Anything else (a checked, non-IO exception — typically InterruptedException stored after a worker thread was interrupted) becomes an ExternalServerException (500): we have no evidence it is the caller's fault, so we keep the bug visible.
      Calling surface at the worker rethrow site lets AsyncExternalSourceOperator's classify(java.lang.Throwable) compose cleanly on the result: an ExternalException returned here passes straight through classify unchanged; a non-classified RuntimeException is the only shape classify still actively re-wraps (into ExternalServerException, the same status surface would have produced for an unrecognized worker fault).
      Parameters:
      failure - the raw stored worker-side throwable; not a status-neutral wrapper around it
      fallbackMessage - non-null context prefix (e.g. the coordinator/iterator name); included in every wrapped result so the throw-site origin survives classification