Error handling#
This guide covers how Planning Engine reports failures in Java: which problems throw, which come back as data inside a normal result, and how to get a live project working again after each kind.
How failures surface#
Planning Engine reports failures with unchecked exceptions. No method declares throws, so the compiler won't remind you to catch anything. Every call is synchronous, so the call itself throws the failure. There's no CompletableFuture to inspect.
Every engine exception extends PlenException, an abstract subclass of RuntimeException. It has two concrete subclasses:
| Exception | Meaning | Project state after the exception |
|---|---|---|
InputException |
The data or options you passed are invalid. | Unchanged. The project keeps the previous accepted state and its cached result. |
ProjectException |
The input may be fine, but the call doesn't fit the project's current state. | Unchanged. You fix it by calling the right method first. |
The public code field of a ProjectException is a ProjectError value:
| Code | getMessage() |
When it occurs |
|---|---|---|
NO_BASELINE |
no-baseline |
preview, loadProfile, evaluate, suggestAssignments or roster on a project that has no solved baseline, including right after apply |
STALE_BASELINE |
stale-baseline |
A baselineId in the options or in SchedulingChanges doesn't name the current baseline |
NEEDS_RESYNC |
needs-resync |
solve(null, โฆ), apply or level after an apply rejected an operation, until a full solve(data, โฆ) succeeds |
Most of what goes wrong in a plan isn't an exception at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected apply operations come back inside the result. An exception means the call itself couldn't run.
Two other parts of the library have their own exception types. SkillLayer.slots throws MatchException, which isn't a PlenException. The JSON API returns error envelopes instead of throwing. A few JDK exceptions, mostly NullPointerException, cover misuse of the types themselves. They're listed under JDK exceptions.
Catching errors#
catch (PlenException e) covers every engine error. Catch the subclasses when you need to tell them apart:
;
;
getMessage() holds different things on the two subclasses:
- On an
InputExceptionit's only the description, such asinvalid duration. The name of the bad field is in the publicpathfield, so read both when you show the error to a user. - On a
ProjectExceptionit's the kebab-case code (no-baseline,stale-baseline,needs-resync). Branch oncode, not on the text.
Input errors#
An InputException carries:
kind, anInputErrorKind. Calls on the typed API produceVALIDATION.PARSEcomes only from JSON decoding:Decode,WireandJsonProject.getMessage(), a short description such asinvalid duration.path, the offending field in its JSON (wire) spelling:tasks[0].duration,assignments[1].id,calendars[0].worktime.days,scope.resources. It'snullfor parse errors and for refusals that concern the whole state, such assuggestAssignmentson a cyclic project.
Paths use the snake_case wire names, not the Java field names. TaskData.constraintDate on the fourth task is reported as tasks[3].constraint_date.
With the default Validation.STRICT, solve(data, โฆ) and Engine.schedule reject the input on the first problem they find. Strict validation rejects, among other things:
- a leaf task with fewer than two of
startDate,endDateandduration, or with all three when they don't agree - a dated constraint (
SNET,FNLT, ...) without aconstraintDate - duplicate task, link or calendar ids (
Id.num(1)andId.str("1")count as the same id) - a cycle in the
parenthierarchy - a summary with
startDate+duration(a summary takes both dates or neither) - a negative
duration, or apriorityoutside 0..1000 - a
Worktime.daysarray that doesn't have exactly 7 entries, and calendars with no working time maxIterations = 0, a non-finite lag, or a negative or non-finitedefaultResourceCapacity
The full list per field is on the reference pages for ProjectData, TaskData and AssignmentData.
Reporting a validation error with its path#
;
;
A rejected replacement keeps the old state#
On a live Project, a solve(data, โฆ) that fails validation installs nothing. The project keeps its accepted baseline, and solve(null, โฆ) still returns the result you had before:
;
;
Project errors#
A ProjectException tells you the call came at the wrong moment. Each code has one recovery:
| Code | Recovery |
|---|---|
NO_BASELINE |
Call solve first. After apply, call solve(null, โฆ) before preview, loadProfile, evaluate or suggestAssignments. |
STALE_BASELINE |
The project was solved, edited or leveled since you read the token. Take baselineId from the latest result and retry. |
NEEDS_RESYNC |
Send the full data again with solve(data, โฆ). Nothing else is accepted until that call succeeds. |
Telling the codes apart#
ProjectError is an enum, so a switch expression on e.code covers every code without a default branch:
;
preview checks both options.baselineId and changes.baselineId. Baseline tokens are opaque and unique to the process, so never store them across processes or compare them in tests.
Recovering from NEEDS_RESYNC#
A rejected operation in apply doesn't throw. It's listed in ApplyReport.rejected and sets needsResync. From then on, solve(null, โฆ), apply and level throw a ProjectException with code == ProjectError.NEEDS_RESYNC, because your model and the project's model no longer agree. Resync with the full data you hold:
;
;
solve deep-copies the data it receives, so you can pass the same ProjectData object again. If the resync data itself fails validation, the InputException leaves the project in the NEEDS_RESYNC state. Fix the data and call solve(data, โฆ) again. The recovery how-to covers this flow in an editor.
Results that are not errors#
These all come back in a normal return value. Check them on every result you show to a user:
| Where | What it means |
|---|---|
ScheduleResult.cycles |
Dependency cycles. A cyclic solve returns unchanged dates and empty updates, and still installs a baseline. |
ScheduleResult.conflicts |
Conflict.ConstraintViolation (a link and a hard constraint disagree, the constraint wins) and Conflict.CalendarNonWorking |
ScheduleResult.notes |
Input the engine dropped (Note.DroppedLink, Note.DroppedTask, Note.DroppedAssignment) or fixed (Note.Repaired) |
ScheduleResult.converged |
false when the planner didn't reach a fixed point within config.maxIterations. The dates are still valid output. |
ApplyReport.rejected |
Operations that apply refused. The other operations in the batch were still applied. |
CandidateResult.error |
An evaluate candidate whose change set was rejected. That candidate has no metrics. The other candidates are scored normally. |
Checking a schedule for problems#
Conflict and Note are abstract classes with a closed set of nested subclasses, so read them with instanceof patterns:
;
;
Dependency cycles are accepted state. Cycles in the parent hierarchy are different: they're invalid input and throw an InputException.
Repair instead of reject#
Validation.STRICT suits data your own code produces. For data from a UI or an older store, Validation.REPAIR coerces it instead of throwing:
- duplicate ids: the last record wins, at the first record's position
- missing dates or durations are derived
- in an inconsistent start/end/duration triple the dates win
- dated constraints without a date are cleared
- invalid
pauseslists and assignment slice values are cleared
Each fix shows up as a Note.Repaired with a path and a message, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still throw an InputException.
;
;
The messy input how-to shows the repair flow end to end.
Errors from SkillLayer.slots#
SkillLayer.slots and SkillLayer.validatePool throw MatchException, an abstract RuntimeException with two nested subclasses. It isn't a PlenException, so catch (PlenException e) doesn't catch it.
| Subclass | Fields | When |
|---|---|---|
MatchException.Invalid |
path, detail |
A bad pool or requirement. path uses the same format as InputException: pool.workers[0].resource, requirements[2].count, requirements[1].assignments[0]. getMessage() is "<path>: <detail>". |
MatchException.Project |
error (the ProjectException), code() |
The project has no clean solved baseline (NO_BASELINE). Call solve before slots, including after apply. |
Java 21 also has java.lang.MatchException. With only import org.dhtmlx.plen.*;, the simple name MatchException is ambiguous and the code doesn't compile. Import the Planning Engine class by name:
;
;
;
Handling slots and suggestions through one type#
toPlenException() converts either subclass into the matching engine exception. Invalid becomes an InputException with kind VALIDATION and the same path, and Project gives back the wrapped ProjectException. If you rethrow the converted exception, a method that calls both slots and suggestAssignments needs only one set of catch blocks:
;
;
;
One slots failure isn't a MatchException. When the summed count of all requirements reaches Integer.MAX_VALUE, slots throws an InputException (kind VALIDATION, path null) before it allocates anything.
Errors in the JSON API#
JsonProject and the Wire calls don't throw engine errors. They catch every PlenException and return an error envelope in place of the result:
The error object has four fields:
| Field | Value |
|---|---|
kind |
parse (malformed JSON, a missing required field, a type mismatch), validation or project |
message |
The exception's getMessage() |
path |
The InputException path, or null |
code |
no-baseline, stale-baseline or needs-resync for kind: "project", otherwise null |
Wire.slots and JsonProject#slots convert a MatchException the same way, so the skill layer's errors arrive as validation or project envelopes too.
The one JSON call that throws is the JsonProject(String optionsJson) constructor. Invalid options throw a JsonProjectException, since there's no envelope to return:
;
getErrorJson() returns the same envelope the other calls would return, and getError() returns the PlenException behind it.
Only PlenException becomes an envelope. A JDK exception from a programming error, such as NullPointerException, still propagates out of Wire and JsonProject. Decode.fromJson is a typed call, so it throws its InputException (kind PARSE or VALIDATION) instead of returning an envelope. The JSON how-to shows a full endpoint.
JDK exceptions#
Outside the engine exceptions, the library throws standard JDK exceptions only for misuse of its types:
| Exception | When |
|---|---|
NullPointerException |
A public method gets null for an argument that isn't documented as nullable. Id.str(null) throws it too, and so does a Patch.of(null) on a field that can't be cleared that way, when the patch is applied. |
IllegalArgumentException |
Execution.parallel(threads) with threads <= 0, Moment.parse of an invalid date, Json.parse of malformed text, and Json.stringify of a non-finite number |
IllegalStateException |
Patch#value() on a patch without a value, Id#key() (and equals / hashCode) of an infinite number, ScheduleResult#acceptedId() without a baseline, and Encode.text of a non-finite number |
UnsupportedOperationException |
Encode.text of an object that has no wire encoder |
OutOfMemoryError |
SkillLayer.slots with requirement counts that are huge but still below the slot limit |
These point at a bug in the calling code, so you rarely catch them. Avoid them up front instead:
;
Valid input doesn't throw anything else. One known bug is a hang, not an exception: level doesn't return when an assignment asks for more units than its resource can ever supply (for example value 1.25 on capacity 1). The same applies to suggestAssignments with level set.