grpc/grpc-go · error

malformed duration %q: missing seconds unit

Error message

malformed duration %q: missing seconds unit

What it means

Returned by Duration.UnmarshalJSON when the JSON duration string does not end with the seconds unit 's'. This type implements the protobuf JSON Duration spec, which encodes durations as quoted strings like "3.5s" or "-1.000000001s"; the trailing 's' is mandatory and case-sensitive. Without it the parser cannot distinguish a duration from a bare number string.

Source

Thrown at internal/serviceconfig/duration.go:67

	}

	// Generated output always contains 0, 3, 6, or 9 fractional digits,
	// depending on required precision.
	str := fmt.Sprintf("%s%d.%09d", sign, sec, ns)
	str = strings.TrimSuffix(str, "000")
	str = strings.TrimSuffix(str, "000")
	str = strings.TrimSuffix(str, ".000")
	return []byte(fmt.Sprintf("\"%ss\"", str)), nil
}

// UnmarshalJSON unmarshals b as a duration JSON string into d.
func (d *Duration) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	if !strings.HasSuffix(s, "s") {
		return fmt.Errorf("malformed duration %q: missing seconds unit", s)
	}
	neg := false
	if s[0] == '-' {
		neg = true
		s = s[1:]
	}
	ss := strings.SplitN(s[:len(s)-1], ".", 3)
	if len(ss) > 2 {
		return fmt.Errorf("malformed duration %q: too many decimals", s)
	}
	// hasDigits is set if either the whole or fractional part of the number is
	// present, since both are optional but one is required.
	hasDigits := false
	var sec, ns int64
	if len(ss[0]) > 0 {
		var err error
		if sec, err = strconv.ParseInt(ss[0], 10, 64); err != nil {
			return fmt.Errorf("malformed duration %q: %v", s, err)

View on GitHub (pinned to 03255a9237)

Solutions

  1. Append the 's' suffix to every duration value: "500ms" becomes "0.500s" (or "0.5s").
  2. Express whole-second values plainly with the suffix, e.g. "10s".
  3. Validate config against the protobuf JSON Duration grammar (optionally signed, fractional with up to 9 digits, mandatory trailing 's').
  4. If generating JSON from Go, marshal through serviceconfig.Duration / protojson rather than writing raw strings.

Example fix

// before
{"timeout": "1500"}

// after
{"timeout": "1.500s"}
Defensive patterns

Strategy: validation

Validate before calling

func validateProtoDurationJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil { return err }
    if !strings.HasSuffix(s, "s") {
        return fmt.Errorf("duration %q missing trailing 's'", s)
    }
    var d serviceconfig.Duration
    return d.UnmarshalJSON(b)
}

Type guard

func isProtoDurationString(s string) bool {
    if !strings.HasSuffix(s, "s") { return false }
    var d serviceconfig.Duration
    return d.UnmarshalJSON([]byte(`"`+s+`"`)) == nil
}

Try / catch

if err := json.Unmarshal(cfg, &sc); err != nil {
    if strings.Contains(err.Error(), "missing seconds unit") {
        // surface a config-level error pointing at the offending field
    }
    return err
}

Prevention

When it happens

Trigger: Any gRPC service config or JSON field typed as serviceconfig.Duration whose value is missing the suffix: "3.5", "100", "1h30m" (Go time.ParseDuration style is not accepted — only the protobuf form). Triggered on json.Unmarshal of config bytes that contain such a value.

Common situations: Reusing Go time.Duration literals ("5m") in JSON config; tooling that emits numeric milliseconds; schema generators that omit the unit; migrating from time.ParseDuration-based config to protobuf JSON.

Understand the failure class

Related errors


AI-assisted analysis of grpc/grpc-go@03255a9237 (2026-08-07). Data as JSON: /api/errors/ac5173c48b2bba16. Report an issue: GitHub.