Unit 10 · lesson

Exceptions in a Running System: Preserve Where the Rule Broke

Week 9 used JUnit to create failures on purpose. A test knew the input in advance and checked the result against an expectation.

Runtime failures are messier. A component may receive a bad value while the rest of the application is doing other work. When that happens, an exception is useful only if the system preserves enough context to understand what rule was violated and where the failure traveled.

You already know how to throw and catch IllegalArgumentException. This lesson focuses on exception propagation and diagnostic context.

Begin with the component rule

Use the validated sensor from Week 8:

class ValidatedRangeSensor {
  private final String id;
  private final double minRangeMeters;
  private final double maxRangeMeters;
  private double rangeMeters;

  ValidatedRangeSensor(
      String id,
      double minRangeMeters,
      double maxRangeMeters,
      double initialRangeMeters
  ) {
    this.id = id;
    this.minRangeMeters = minRangeMeters;
    this.maxRangeMeters = maxRangeMeters;
    updateRange(initialRangeMeters);
  }

  void updateRange(double newRangeMeters) {
    if (newRangeMeters < minRangeMeters
        || newRangeMeters > maxRangeMeters) {
      throw new IllegalArgumentException(
          "Sensor " + id
          + " rejected " + newRangeMeters + " m"
          + "; allowed="
          + minRangeMeters + ".."
          + maxRangeMeters + " m"
      );
    }

    rangeMeters = newRangeMeters;
  }

  String getId() {
    return id;
  }

  double getRangeMeters() {
    return rangeMeters;
  }
}

An invalid call such as:

sensor.updateRange(3.70);

throws before the field changes.

That preserves the object's last valid state. It also interrupts the normal path of the current method.

An exception travels until something handles it

Suppose another method receives raw values:

void processReading(
    ValidatedRangeSensor sensor,
    double value
) {
  sensor.updateRange(value);
}

and a higher-level method calls it:

void processBatch(
    ValidatedRangeSensor sensor,
    double[] readings
) {
  for (double value : readings) {
    processReading(sensor, value);
  }
}

If updateRange(...) throws and neither processReading(...) nor processBatch(...) catches the exception, normal execution leaves those calls and the exception continues upward through the call stack.

That behavior is called propagation.

You do not need a try/catch in every method. The important design question is where enough context exists to respond meaningfully.

Catching too low can hide useful context

This implementation catches the error inside the component method:

void updateRange(double newRangeMeters) {
  try {
    if (newRangeMeters < minRangeMeters
        || newRangeMeters > maxRangeMeters) {
      throw new IllegalArgumentException(
          "invalid range"
      );
    }

    rangeMeters = newRangeMeters;
  } catch (IllegalArgumentException error) {
    System.out.println("bad value");
  }
}

The caller now receives no exception.

The component printed bad value, but the code that submitted the reading may not know the update failed. The message also lost the component ID, value, units, and allowed interval.

The exception was technically "handled," but the diagnostic record became worse.

Catch where you can add meaning

A processing layer may know more:

void processReading(
    ValidatedRangeSensor sensor,
    double value
) {
  try {
    sensor.updateRange(value);
  } catch (IllegalArgumentException error) {
    System.err.println(
        "range update rejected"
        + " id=" + sensor.getId()
        + " value=" + value + " m"
        + " reason=" + error.getMessage()
    );
  }
}

Now the record contains:

range update rejected id=range-front value=3.7 m reason=Sensor range-front rejected 3.7 m; allowed=0.1..2.0 m

The code still has not diagnosed the root cause. It has preserved a stronger observation.

A stack trace shows the call path

If an exception is not caught, Java usually reports a stack trace.

A simplified example might look like:

Exception in thread "main" java.lang.IllegalArgumentException:
Sensor range-front rejected 3.7 m; allowed=0.1..2.0 m
    at ValidatedRangeSensor.updateRange(ValidatedRangeSensor.java:24)
    at RangeProcessor.processReading(RangeProcessor.java:11)
    at RangeProcessor.processBatch(RangeProcessor.java:18)
    at RobotDemo.main(RobotDemo.java:9)

Read it from the exception outward.

The first line identifies the exception type and message.

The next frames show methods involved in the call path. The exact file names and line numbers depend on the program.

A stack trace can answer:

Which call path reached the failure?

It cannot automatically answer:

Why did the original value become 3.70?

That may require data-source, conversion, configuration, or runtime evidence.

Preserve the original exception when adding context

Sometimes code catches one exception and throws another with higher-level context.

This is weak:

catch (IllegalArgumentException error) {
  throw new RuntimeException("range processing failed");
}

The new exception loses the original cause unless you preserve it.

A stronger version is:

catch (IllegalArgumentException error) {
  throw new RuntimeException(
      "range processing failed for "
      + sensor.getId(),
      error
  );
}

The original exception becomes the cause of the new exception.

Now diagnostic tooling can preserve both layers:

  • high-level operation that failed;
  • lower-level rule violation that triggered it.

Do not wrap every exception automatically. Add a new layer only when it contributes useful context.

Catching does not mean recovery succeeded

Suppose your code does this:

try {
  sensor.updateRange(3.70);
} catch (IllegalArgumentException error) {
  System.err.println(error.getMessage());
}

Afterward, the program continues.

That proves the exception was caught.

It does not prove the system recovered safely.

Questions remain:

  • Is the last valid sensor value still safe to use?
  • Should the component be marked stale?
  • Should motion be inhibited?
  • Should the value be requested again?
  • Was the invalid input a one-time outlier or a persistent fault?

Those are recovery-policy decisions.

Detection, context, and policy are different jobs

The component can detect:

3.70 m violates the declared 0.10..2.00 m interval

The processing layer can add context:

range-front received the value during a range update

A higher-level robot policy may decide:

hold the last valid state, mark the source unhealthy, and block movement until another check passes

Keeping those responsibilities separate makes the code easier to inspect and test.

Worked case: two invalid values in a batch

Input:

double[] readings = {
    0.42,
    3.70,
    0.40,
    -0.25
};

A processor catches each invalid update and continues:

for (double value : readings) {
  try {
    sensor.updateRange(value);

    System.out.println(
        "accepted id=" + sensor.getId()
        + " value=" + value + " m"
    );
  } catch (IllegalArgumentException error) {
    System.err.println(
        "rejected id=" + sensor.getId()
        + " value=" + value + " m"
        + " reason=" + error.getMessage()
    );
  }
}

The expected evidence is:

accepted id=range-front value=0.42 m
rejected id=range-front value=3.7 m ...
accepted id=range-front value=0.4 m
rejected id=range-front value=-0.25 m ...

That record tells you which requests were accepted or rejected.

It still does not tell you whether continuing after each rejection is the correct robot policy. The example is testing a diagnostic strategy, not declaring a universal safety rule.

Common mistake: logging only the exception message

This:

System.err.println(error.getMessage());

may be enough in a tiny demonstration.

In a system with several components, the same exception text can occur in multiple places.

Useful runtime context often includes:

  • component ID;
  • value and unit;
  • operation being attempted;
  • relevant limits or configuration;
  • time/order information;
  • exception type;
  • source method or logger identity when available.

Week 10 will use actual logging systems to preserve that context more systematically.

Your turn

Read this code:

void processReading(
    ValidatedRangeSensor sensor,
    double value
) {
  try {
    sensor.updateRange(value);
  } catch (IllegalArgumentException error) {
    System.out.println("error");
  }
}

Rewrite the catch block so another student could identify:

  • which component rejected the update;
  • which value was rejected;
  • the unit;
  • the original reason.

Then answer:

What additional evidence would you need before claiming you know why the invalid value occurred?

In the next lesson, you will replace ad hoc print statements with structured, timestamped robot telemetry using WPILib's data logging tools.