{"record":{"id":"69612fcf474422ec","repo":"instructure/canvas-lms","slug":"invalid-until-until-date","errorCode":null,"errorMessage":"Invalid UNTIL '%{until_date}'","messagePattern":"Invalid UNTIL '%(.+?)'","errorType":"validation","errorClass":"RruleValidationError","httpStatus":null,"severity":"error","filePath":"app/helpers/rrule_helper.rb","lineNumber":70,"sourceCode":"    Hash[*rrule.sub(/^RRULE:/, \"\").split(/[;=]/)]\n  end\n\n  def rrule_validate_common_opts(rropts)\n    raise RruleValidationError, I18n.t(\"Missing INTERVAL\") unless rropts.key?(\"INTERVAL\")\n    raise RruleValidationError, I18n.t(\"INTERVAL must be > 0\") unless rropts[\"INTERVAL\"].to_i > 0\n\n    # We do not support never ending series because each event in the series\n    # must get created in the db to support the paginated calendar_events api\n    raise RruleValidationError, I18n.t(\"Missing COUNT or UNTIL\") unless rropts.key?(\"COUNT\") || rropts.key?(\"UNTIL\")\n\n    if rropts.key?(\"COUNT\")\n      raise RruleValidationError, I18n.t(\"COUNT must be > 0\") unless rropts[\"COUNT\"].to_i > 0\n      raise RruleValidationError, I18n.t(\"COUNT must be <= %{limit}\", limit: RruleHelper::RECURRING_EVENT_LIMIT) unless rropts[\"COUNT\"].to_i <= RruleHelper::RECURRING_EVENT_LIMIT\n    else\n      begin\n        format_date(rropts[\"UNTIL\"])\n      rescue\n        raise RruleValidationError, I18n.t(\"Invalid UNTIL '%{until_date}'\", until_date: rropts[\"UNTIL\"])\n      end\n    end\n  end\n\n  private\n\n  DAYS_OF_WEEK = {\n    \"SU\" => I18n.t(\"Sun\"),\n    \"MO\" => I18n.t(\"Mon\"),\n    \"TU\" => I18n.t(\"Tue\"),\n    \"WE\" => I18n.t(\"Wed\"),\n    \"TH\" => I18n.t(\"Thu\"),\n    \"FR\" => I18n.t(\"Fri\"),\n    \"SA\" => I18n.t(\"Sat\")\n  }.freeze\n  MONTHS = [\n    nil,\n    I18n.t(\"January\"),","sourceCodeStart":52,"sourceCodeEnd":88,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/helpers/rrule_helper.rb#L52-L88","documentation":"When no COUNT key is present, the UNTIL branch attempts format_date(rropts[\"UNTIL\"]). If that parsing raises (missing/blank value, non-date string, unexpected format), the rescue block re-raises RruleValidationError with \"Invalid UNTIL '%{until_date}'\" including the raw value.","triggerScenarios":"Calling rrule_to_natural_language with \"UNTIL\" => \"\" , \"UNTIL\" => \"not-a-date\", or a value whose split(\"T\")[0] portion cannot be parsed by format_date — while \"COUNT\" is absent from rropts.","commonSituations":"Clients send UNTIL in a non-ISO format (e.g. '2024-12-31 23:59:59' with a space, or a human-readable date); blank UNTIL from an unset form field; ICS exports with UNTIL values in formats format_date does not accept.","solutions":["Send UNTIL as an ISO-8601 date or datetime string, e.g. UNTIL=20241231T235959Z or 2024-12-31","Verify the RRULE parsing code is not stripping or corrupting the UNTIL value before validation","Prefer COUNT when the end condition is occurrence-count based and formatting dates is error-prone"],"exampleFix":"// before\n\"FREQ=DAILY;INTERVAL=1;UNTIL=tomorrow\"\n// after\n\"FREQ=DAILY;INTERVAL=1;UNTIL=2024-12-31T23:59:59Z\"","handlingStrategy":"validation","validationCode":"until_val = rropts[\"UNTIL\"]\nraise ArgumentError, \"invalid UNTIL\" unless until_val.is_a?(String) && until_val.match?(/\\A\\d{4}-?\\d{2}-?\\d{2}(T.*)?\\z/)","typeGuard":null,"tryCatchPattern":"begin\n  RruleHelper.rrule_to_natural_language(rropts)\nrescue RruleValidationError => e\n  render json: { errors: [e.message] }, status: :bad_request\nend","preventionTips":["Always send UNTIL in ISO-8601 form","Never send blank UNTIL strings — omit the key and use COUNT instead","Normalize formats from ICS imports before validation"],"tags":["rrule","validation","date-format","calendar"],"backgroundTag":"invalid-date-format","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}