To parse ISO 8601 timestamps robustly with standard Java time APIs, use java.time (Instant / OffsetDateTime / ZonedDateTime) and handle common pitfalls (leap-second notation, missing timezone). Below is a concise implementation that:- Accepts strings like "2023-11-15T13:45:30Z" and "2023-11-15T13:45:30+02:00"- Treats timestamps without zone/offset as UTC (configurable)- Normalizes leap-second "60" in seconds by mapping to "59" and adding one second to the resulting Instant- Throws a descriptive checked exception on invalid inputjava
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
public class Iso8601Parser {
// Checked exception for parsing errors
public static class IsoParseException extends Exception {
public IsoParseException(String message, Throwable cause) { super(message, cause); }
public IsoParseException(String message) { super(message); }
}
/**
* Parse an ISO 8601 timestamp and return epoch milliseconds.
* Accepts offsets or 'Z'. If input has no timezone/offset, it's treated as UTC.
* Handles leap-second notation "60" by normalizing to "59" and adding 1 second.
*/
public static long parseIso8601ToEpochMillis(String iso) throws IsoParseException {
if (iso == null || iso.isBlank()) throw new IsoParseException("Input timestamp is null or empty");
String input = iso.trim();
// Detect leap second "60" in seconds field like ":59:60" or "T23:59:60"
// Use regex to find seconds=60; simple approach:
if (input.matches(".*T.*:(60)(?:[.,]\\d+)?(?:Z|[+-].*|$)")) {
// Replace ":60" with ":59" and remember to add 1 second after parsing
input = input.replaceFirst(":(60)(?=[.,Z+\\-]|$)", ":59");
try {
Instant parsed = parseWithZoneFallback(input);
return parsed.plusSeconds(1).toEpochMilli();
} catch (IsoParseException e) {
throw new IsoParseException("Failed to parse leap-second timestamp: " + iso, e);
}
}
// Normal path
try {
Instant parsed = parseWithZoneFallback(input);
return parsed.toEpochMilli();
} catch (DateTimeParseException e) {
throw new IsoParseException("Invalid ISO 8601 timestamp: " + iso, e);
}
}
// Helper: try parsing with Instant, OffsetDateTime, or assume UTC if no offset present
private static Instant parseWithZoneFallback(String input) throws DateTimeParseException, IsoParseException {
// Fast path: ISO_INSTANT handles "Z" and fractional seconds with 'Z'
try {
return Instant.parse(input);
} catch (DateTimeParseException ignored) { }
// Try parsing as OffsetDateTime (handles "+02:00")
try {
OffsetDateTime odt = OffsetDateTime.parse(input, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
return odt.toInstant();
} catch (DateTimeParseException ignored) { }
// If no offset present, treat as local timestamp — here we choose UTC as default policy
// Example inputs: "2023-11-15T13:45:30" -> interpret as 13:45:30 UTC
if (!input.endsWith("Z") && !input.matches(".*[+-]\\d{2}:?\\d{2}$")) {
try {
LocalDateTime ldt = LocalDateTime.parse(input, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
return ldt.toInstant(ZoneOffset.UTC); // policy: assume UTC
} catch (DateTimeParseException e) {
throw e;
}
}
throw new DateTimeParseException("Unrecognized ISO 8601 format", input, 0);
}
}
Key points and reasoning:- Use Instant/OffsetDateTime for precise, timezone-aware conversion to epoch millis.- Leap seconds: Java time APIs do not natively represent a 60th second; we normalize "60" -> "59" then add one second to preserve the intended instant.- Missing timezone: choose a clear policy. This implementation treats timestamps without offset as UTC; in some systems you may prefer to reject such inputs and force callers to provide offsets—either approach should be documented and consistent.- Errors: wrapping DateTimeParseException in a checked IsoParseException gives callers a descriptive, catchable exception.Edge cases:- Invalid formats (throws IsoParseException)- Null/empty input- Fractional seconds (handled by parsers)- Large or past/future dates (Instant handles the full range)