instructure/canvas-lms · error · RruleValidationError

Invalid BYMONTH

Error message

Invalid BYMONTH '%{bymonth}'

What it means

parse_bymonth converts the BYMONTH value with to_i and requires it to be between 1 and 12 (January–December). Values outside that range — including non-numeric strings that become 0 — raise RruleValidationError with the raw value interpolated.

Solutions

  1. Send BYMONTH as an integer 1–12 (1 = January, 12 = December)
  2. Convert zero-based JS month values before building the RRULE: month + 1
  3. Validate month inputs at the API boundary before constructing the RRULE string

Example fix

// before
"FREQ=YEARLY;INTERVAL=1;BYMONTH=0" // JS getMonth() leaked through
// after
"FREQ=YEARLY;INTERVAL=1;BYMONTH=12"
Defensive patterns

Strategy: validation

Validate before calling

month = bymonth.to_i
raise ArgumentError, "BYMONTH must be 1-12" unless month.between?(1, 12)

Try / catch

begin
  RruleHelper.rrule_to_natural_language(rropts)
rescue RruleValidationError => e
  render json: { errors: [e.message] }, status: :bad_request
end

Prevention

When it happens

Trigger: Calling rrule_to_natural_language with a yearly RRULE where BYMONTH is 0, 13, "abc", or an empty string (to_i => 0, failing between?(1,12)).

Common situations: Client confusion between zero-based month indexes (0–11 from JS Date.getMonth()) and RFC-5545 one-based months; parsing errors emitting empty BYMONTH; UIs storing month offsets instead of month numbers.

Understand the failure class

Background: "value must be between 0 and 1" / "out of range" / "must not be negative" errors: fixing range-validation failures across open-source libraries — this error's family across 42 libraries.

Related errors


AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15). Data as JSON: /api/errors/64dae1e7e4fd06cd. Report an issue: GitHub.

Appendix: source

Thrown at app/helpers/rrule_helper.rb:132

  def join_month_dys(days)
    days.join(",")
  end

  def parse_byday(byday)
    byday.split(",").map do |d|
      match = /\A([-+]?\d+)?([A-Z]{2})\z/.match(d)
      raise RruleValidationError, I18n.t("Invalid BYDAY '%{byday}'", byday:) unless match

      {
        occurrence: match[1].to_i,
        day_of_week: DAYS_OF_WEEK[match[2]]
      }
    end
  end

  def parse_bymonth(bymonth)
    month = bymonth.to_i
    raise RruleValidationError, I18n.t("Invalid BYMONTH '%{bymonth}'", bymonth:) unless month.between?(1, 12)

    month
  end

  def parse_bymonthday(bymonthday, month)
    raise RruleValidationError, I18n.t("Unsupported BYMONTHDAY, only a single day is permitted.") unless bymonthday.split(",").length == 1

    monthday = bymonthday.to_i

    # not validating if we're in a leap year
    raise RruleValidationError, I18n.t("Invalid BYMONTHDAY '%{bymonthday}'", bymonthday:) unless monthday.between?(1, DAYS_IN_MONTH[month])

    monthday
  end

  def format_date(date_str)
    date = date_str.split("T")[0]
    year = date[0, 4].to_i

View on GitHub (pinned to 1c9f0bb801)