grpc/grpc-go · error

malformed duration : missing seconds unit

Error message

malformed duration %q: missing seconds unit

What it means

serviceconfig.Duration.UnmarshalJSON (duration.go:60-68) parses a protobuf-JSON duration string, which must end with a single 's' (seconds unit), e.g. "1.5s", "0.250s", "-3s". If the suffix is missing, the string is rejected. This type backs gRPC service-config duration fields (timeouts, hedging, retry backoff).

Solutions

  1. Express durations in protobuf-JSON form with a trailing 's': "0.500s", "3s", "1.5s".
  2. Convert from Go time strings programmatically before serializing: use time.ParseDuration then format as seconds with up to 9 fractional digits + 's'.
  3. Validate config files with a JSON schema that enforces the '^-?\d+(\.\d{1,9})?s$' shape.

Example fix

// before (service config JSON):
//   { "retryPolicy": { "initialBackoff": "500ms" } }
//   // error: malformed duration "500ms": missing seconds unit

// after:
//   { "retryPolicy": { "initialBackoff": "0.500s" } }
Defensive patterns

Strategy: validation

Validate before calling

package main

import (
	"fmt"
	"regexp"
	"time"
)

var durationRE = regexp.MustCompile(`^-?\d+(\.\d{1,9})?s$`)

func formatDuration(d time.Duration) string {
	// Produce a protobuf-JSON duration string.
	return time.Duration(d).String() // then convert below
}

func validateDurationJSON(s string) error {
	if !durationRE.MatchString(s) {
		return fmt.Errorf("duration %q must end with 's' and have <=9 fractional digits", s)
	}
	return nil
}

func _unused() { _ = formatDuration }

// func main() { _ = validateDurationJSON("0.5s") }

Try / catch

// JSON unmarshal into serviceconfig.Duration returns this error.
//
//   var d serviceconfig.Duration
//   if err := json.Unmarshal([]byte(raw), &d); err != nil {
//       if strings.Contains(err.Error(), "missing seconds unit") {
//           // append the trailing 's' and retry, or surface to the user.
//       }
//   }

Prevention

When it happens

Trigger: Triggered when JSON unmarshaling into a serviceconfig.Duration (or any struct embedding it) receives a string without a trailing 's'. Examples: "100ms", "5", "1.5" — none end in 's'.

Common situations: Hand-written service config JSON uses Go time-string syntax ("500ms") instead of protobuf-JSON syntax ("0.500s"), a config field is set as a bare number, or a translation layer omitted the unit.

Understand the failure class

Related errors


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

Appendix: 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 0c51461d27)