quarkusio/quarkus · error · IllegalArgumentException

unterminated escape sequence at end of string: ${s}

Error message

unterminated escape sequence at end of string: ${s}

What it means

URIDecoder.decodeURIComponent() percent-decodes a URI component. A '%' must be followed by two hex digits ('%XX'); if the string ends immediately after a '%', there is no escape sequence to decode, so this IllegalArgumentException is thrown. It is a strict-input guard: the decoder refuses to silently drop a truncated escape.

Source

Thrown at independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/URIDecoder.java:67

        boolean modified = false;
        int i;
        for (i = 0; i < size; i++) {
            final char c = s.charAt(i);
            if (c == '%' || (plus && c == '+')) {
                modified = true;
                break;
            }
        }
        if (!modified) {
            return s;
        }
        final byte[] buf = s.getBytes(StandardCharsets.UTF_8);
        int pos = i; // position in `buf'.
        for (; i < size; i++) {
            char c = s.charAt(i);
            if (c == '%') {
                if (i == size - 1) {
                    throw new IllegalArgumentException("unterminated escape"
                            + " sequence at end of string: " + s);
                }
                c = s.charAt(++i);
                if (c == '%') {
                    buf[pos++] = '%'; // "%%" -> "%"
                    break;
                }
                if (i >= size - 1) {
                    throw new IllegalArgumentException("partial escape"
                            + " sequence at end of string: " + s);
                }
                c = decodeHexNibble(c);
                final char c2 = decodeHexNibble(s.charAt(++i));
                if (c == Character.MAX_VALUE || c2 == Character.MAX_VALUE) {
                    throw new IllegalArgumentException(
                            "invalid escape sequence `%" + s.charAt(i - 1)
                                    + s.charAt(i) + "' at index " + (i - 2)
                                    + " of: " + s);

View on GitHub (pinned to e1c734241f)

Solutions

  1. Encode literal percent signs as %25 before decoding
  2. Validate the input matches /^([^%]|%[0-9A-Fa-f]{2})*$/ before decoding
  3. Trim or repair the truncated escape character from the input string
  4. Catch IllegalArgumentException and return the input or a 400 response to the client

Example fix

// before
String v = URIDecoder.decodeURIComponent("discount=100%");
// after
String v = URIDecoder.decodeURIComponent("discount=100%25");
Defensive patterns

Strategy: validation

Validate before calling

private static final Pattern SAFE = Pattern.compile("^([^%]|%[0-9A-Fa-f]{2})*$");
static boolean isDecodable(String s) { return s != null && SAFE.matcher(s).matches(); }
// call: if (!isDecoded(input)) reject 400;

Type guard

static boolean endsWithCompleteEscape(String s) {
    return s != null && !s.endsWith("%");
}

Try / catch

try {
    decoded = URIDecoder.decodeURIComponent(raw);
} catch (IllegalArgumentException e) {
    if (e.getMessage() != null && e.getMessage().contains("unterminated escape")) {
    throw new BadRequestException("Malformed URI component: " + raw);
    } else throw e;
}

Prevention

When it happens

Trigger: Calling decodeURIComponent (or the delegating decode overload) with a string whose last character is '%', e.g. "100%", "/path%", or a URL that was itself truncated or badly encoded.

Common situations: Decoding user-supplied query/path fragments that contain literal percent signs; truncation when storing/pasting URLs; double-encoding mistakes where '%' ended up unescaped in the input.

Related errors


AI-assisted analysis of quarkusio/quarkus@e1c734241f (2026-09-05). Data as JSON: /api/errors/a68fcfbe76431dd3. Report an issue: GitHub.