Stage 3 — Error Messages and Diagnostics

What this stage teaches

Stage 3 is the first stage where your change alters what a user sees when something goes wrong. The skill is producing actionable diagnostics: turning a vague IllegalArgumentException: bad value into a message that tells the operator exactly which field, what was wrong, and what would have been valid. The places this lives:

  • Request validation*Request.validate() returning an ActionRequestValidationException with one entry per problem.
  • Parse and argument errorsIllegalArgumentException (and the XContent parse exceptions) thrown from QueryBuilder/AggregationBuilder/Setting parsing, with text that names the offending token.
  • REST rendering — how an OpenSearchException becomes the JSON error body and HTTP status a client receives, via RestStatus and toXContent.
  • Operator-facing explanations — the family of "explain" responses (_cluster/allocation/explain, _validate/query, profile) whose entire value is the quality of their human-readable text.

The blast radius is still small — you are changing strings and validation, not control flow — but now the message is part of the contract, and a unit test must pin its exact text so it does not silently regress.

Prerequisite: Stages 1–2. You also want a working mental model of the REST layer: REST → Transport → Action.


Where errors are born and where they surface

flowchart LR
  A[HTTP request] --> B[RestHandler.prepareRequest]
  B -->|bad syntax| E1[IllegalArgumentException / XContentParseException]
  B --> C[ActionRequest.validate]
  C -->|null/empty/conflicting fields| E2[ActionRequestValidationException]
  C --> D[TransportAction.doExecute]
  D -->|runtime failure| E3[OpenSearchException subclass]
  E1 --> R[RestController error path]
  E2 --> R
  E3 --> R
  R --> J[JSON error body + RestStatus]

The reader's whole job in this stage is to improve the text at E1, E2, or E3 and to confirm it renders correctly at R. Three exception families matter:

ExceptionThrown fromHTTP statusCarries field context?
ActionRequestValidationException*Request.validate()400yes — a list of messages
IllegalArgumentExceptionparsing, setting validation, builder construction400 (mapped)only if you put it there
OpenSearchException (and subclasses like ResourceNotFoundException, IndexNotFoundException)action executionper subclass (status())structured, renders to XContent

The mapping from a thrown exception to an HTTP status lives in ExceptionsHelper / OpenSearchException. Find it:

grep -rn "status()" server/src/main/java/org/opensearch/OpenSearchException.java | head
grep -rn "class IndexNotFoundException" server/src/main/java/org/opensearch/

Finding Stage 3 issues

is:issue is:open label:bug no:assignee "error message" in:title,body
is:issue is:open label:bug no:assignee "misleading" in:title,body
is:issue is:open label:"good first issue" no:assignee "validation" in:title,body
is:issue is:open label:enhancement no:assignee "exception" in:title

Fallback grep — vague messages are easy to find: short, context-free strings thrown as IllegalArgumentException.

# Bare exceptions with no field/value context:
grep -rn 'throw new IllegalArgumentException("[a-z ]\{1,25\}")' server/src/main/java/ | head
# validate() methods that add a generic message:
grep -rn 'addValidationError("' server/src/main/java/org/opensearch/action/ | head

A message like "invalid value" or "field is required" (no field name, no actual value) is a candidate.


Walked example — a vague parse error becomes a precise one

Illustrative of the pattern. The real site and class come from the grep; do not trust these line numbers.

Symptom: a user posts a malformed aggregation and gets back:

{ "error": { "type": "illegal_argument_exception", "reason": "Unknown key" }, "status": 400 }

"Unknown key" — but which key, in which aggregation, and what keys were valid? The user cannot tell. We will make the message name the offending key, the aggregation, and list the accepted keys.

Locate the throw site

grep -rn '"Unknown key"\|Unknown key for' server/src/main/java/org/opensearch/search/aggregations/ | head
# Common pattern: parsers switch on the current XContent field name:
grep -rn "parseFieldMatcher\|currentFieldName\|token == XContentParser.Token.FIELD_NAME" \
  server/src/main/java/org/opensearch/search/aggregations/bucket/ | head

Open the parser the grep points at. The block typically looks like:

} else {
    throw new IllegalArgumentException("Unknown key");
}

Diff

--- a/server/src/main/java/org/opensearch/search/aggregations/bucket/histogram/DateHistogramAggregationBuilder.java
+++ b/server/src/main/java/org/opensearch/search/aggregations/bucket/histogram/DateHistogramAggregationBuilder.java
@@
-        } else {
-            throw new IllegalArgumentException("Unknown key");
-        }
+        } else {
+            throw new IllegalArgumentException(
+                "Unknown key [" + currentFieldName + "] for ["
+                    + NAME + "] aggregation [" + aggregationName
+                    + "]. Valid keys are " + SUPPORTED_FIELDS + "."
+            );
+        }

The rules for a good diagnostic, in priority order:

  1. Name the thing that was wrong — the offending key/value, in [...] so it is exact even when empty or whitespace.
  2. Locate it — which aggregation/query/field, by name, so the user can find it in a large request body.
  3. Say what would have been right — the valid set, or a hint. Do not dump an enormous list; if the valid set is huge, name the category ("a numeric field type") instead.
  4. Do not leak internals — class names, stack traces, or absolute paths belong in logs, not in the user-facing reason.

Pin the message with a unit test

A diagnostic with no test will regress the next time someone refactors the parser. Assert the exact text:

public void testUnknownKeyMessageNamesTheKey() {
    String json = "{ \"date_histogram\": { \"bogus_key\": 5 } }";
    XContentParser parser = createParser(JsonXContent.jsonXContent, json);
    IllegalArgumentException e = expectThrows(
        IllegalArgumentException.class,
        () -> DateHistogramAggregationBuilder.parse("agg1", parser)
    );
    assertThat(e.getMessage(), containsString("Unknown key [bogus_key]"));
    assertThat(e.getMessage(), containsString("aggregation [agg1]"));
}

expectThrows and the Hamcrest containsString matcher are standard in OpenSearchTestCase. Prefer containsString over assertEquals on the whole message so a later, additive improvement to the text does not break the test for no reason.

Verify the REST rendering

Confirm the improved message actually reaches the client with the right status:

./gradlew run &
curl -s -XPOST 'localhost:9200/idx/_search' -H 'content-type: application/json' -d '{
  "aggs": { "agg1": { "date_histogram": { "bogus_key": 5 } } }
}' | jq '.error.reason, .status'

You should see the new reason text and 400. If the status is wrong, the exception's status() mapping (not the message) is the real bug — a different, deeper fix.

Build, CHANGELOG, PR

./gradlew :server:test --tests "*DateHistogramAggregationBuilderTests" -q
./gradlew :server:precommit
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ ### Fixed
+- Improve the parse error for unknown keys in `date_histogram` to name the key and aggregation ([#NNNNN](...))
git add server/.../DateHistogramAggregationBuilder.java server/.../DateHistogramAggregationBuilderTests.java CHANGELOG.md
git commit -s -m "Improve unknown-key parse error in date_histogram aggregation"
git push && gh pr create --repo opensearch-project/OpenSearch --fill

A second pattern — request validation (validate())

The *Request.validate() method is where pre-execution checks belong. Each problem is a separate entry, so a user with three mistakes sees all three at once:

@Override
public ActionRequestValidationException validate() {
    ActionRequestValidationException e = null;
    if (indices == null || indices.length == 0) {
        e = addValidationError("at least one index is required", e);
    }
    if (maxDocs < 0) {
        e = addValidationError("max_docs [" + maxDocs + "] must not be negative", e);
    }
    return e;   // null means "valid"
}

When you improve a validate(), the test uses the request object directly — no cluster needed:

public void testNegativeMaxDocsIsRejected() {
    MyRequest req = new MyRequest("idx").maxDocs(-1);
    ActionRequestValidationException e = req.validate();
    assertNotNull(e);
    assertThat(e.validationErrors(), hasItem(containsString("max_docs [-1] must not be negative")));
}

This is the cheapest, most deterministic test in the whole roadmap — no InternalTestCluster, no randomness beyond the seed. It is why validation fixes are a great Stage 3 staple.


The "explain"-style responses

Some endpoints exist only to produce good diagnostics; their text is the feature:

  • _cluster/allocation/explain — why a shard is unassigned, decider by decider. The text comes from each AllocationDecider.canAllocate(...) Decision and its Explanation. Improving one of these straddles Stage 3 and Stage 5.
  • _validate/query?explain=true — why a query is invalid or how it rewrites.
  • _search?explain=true and the profile API — scoring/timing breakdowns.

If your issue is "the allocation explain output is confusing," read shard allocation first: you are not changing why a shard is unassigned, only how clearly the system says so.


Pitfalls

  • Leaking internals into reason. Stack traces, class names, and file paths go to the log, not the user-facing message. Users see error.reason; operators see the log.
  • Over-specifying the test. assertEquals on the entire message string breaks on any future wording tweak. Assert the load-bearing substrings with containsString.
  • Changing the HTTP status by accident. Message text and status() are independent. If you only meant to improve wording, do not touch the exception type — that changes the status a client keys off.
  • Concatenating user input into the message unsafely. Always bracket it ([" + value + "]") so a null/empty/whitespace value is visible and cannot be mistaken for surrounding prose.
  • Improving one message and missing its siblings. If the same vague string is thrown in five places, grep them all; fix them in one PR only if they are the same logical message, otherwise file follow-ups.
  • Forgetting validation runs before execution. A check that needs cluster state belongs in the transport action, not validate()validate() runs on the calling node with no cluster state available.

Exit criteria — when you're ready for Stage 4

  • One error-message or validation PR is merged with a unit test asserting the exact diagnostic substrings.
  • No reviewer had to ask "which field?" or "what was the value?" — your first draft already named both.
  • You can trace, for any thrown exception, what HTTP status and JSON body the client gets, using OpenSearchException.status() and the REST error path.
  • You understand why validate() errors are cheap to test and why "explain"-style outputs are diagnostics, not control flow.

You have now followed the request from REST through validation. Stage 4 follows it one layer deeper — into the cluster-manager service, where the cluster's shared state is mutated.