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 anActionRequestValidationExceptionwith one entry per problem. - Parse and argument errors —
IllegalArgumentException(and the XContent parse exceptions) thrown fromQueryBuilder/AggregationBuilder/Settingparsing, with text that names the offending token. - REST rendering — how an
OpenSearchExceptionbecomes the JSON error body and HTTP status a client receives, viaRestStatusandtoXContent. - 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:
| Exception | Thrown from | HTTP status | Carries field context? |
|---|---|---|---|
ActionRequestValidationException | *Request.validate() | 400 | yes — a list of messages |
IllegalArgumentException | parsing, setting validation, builder construction | 400 (mapped) | only if you put it there |
OpenSearchException (and subclasses like ResourceNotFoundException, IndexNotFoundException) | action execution | per 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:
- Name the thing that was wrong — the offending key/value, in
[...]so it is exact even when empty or whitespace. - Locate it — which aggregation/query/field, by name, so the user can find it in a large request body.
- 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.
- 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 eachAllocationDecider.canAllocate(...)Decisionand itsExplanation. 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=trueand 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 seeerror.reason; operators see the log. - Over-specifying the test.
assertEqualson the entire message string breaks on any future wording tweak. Assert the load-bearing substrings withcontainsString. - 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,
grepthem 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.