mcpField
F
frappe-errors-controllers

frappe-errors-controllers

github.com/impertio-studioDevelopment198115 installsUpdated 30w ago
Repository

About frappe-errors-controllers

A controller error guide for Frappe developers, mapping causes and fixes by lifecycle phase and error message, with common issues in hooks, transactions, and permissions.

Key features

  1. 01Diagnosis by lifecycle phase
  2. 02Error, cause, and fix reference
  3. 03Naming and validation troubleshooting
  4. 04Hook overrides and parent-call guidance
  5. 05Transaction rollback timing
  6. 06Permission query and recursion safeguards

Use cases

  1. 01Diagnose controller lifecycle errors
  2. 02Fix naming and submission failures
  3. 03Prevent hook recursion and transaction issues
  4. 04Review permission queries and cancellation cleanup

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/impertio-studio/frappe_claude_skill_package --skill frappe-errors-controllers

After installing, type/frappe-errors-controllersin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

references/
anti-patterns.md
examples.md
patterns.md

Details

From the project README

Controller Errors — Diagnosis and Resolution

Cross-refs: frappe-syntax-controllers (syntax), frappe-impl-controllers (workflows), frappe-errors-serverscripts (server scripts).


Error Diagnosis by Lifecycle Phase

CONTROLLER ERROR
│
├─► NAMING PHASE (autoname / before_naming)
│   ├─► NamingSeries not set → Add naming_series field or autoname property
│   ├─► DuplicateEntryError → Name collision, check uniqueness
│   └─► "name cannot be set directly" → Use autoname method, not self.name = x
│
├─► VALIDATION PHASE (before_validate / validate / before_save)
│   ├─► Infinite recursion → doc.save() called inside validate
│   ├─► Validation skipped → Missing super().validate() in override
│   └─► Wrong error timing → Use validate, not on_update, to block save
│
├─► SAVE PHASE (before_save / on_update / after_insert)
│   ├─► Changes lost in on_update → Use db_set(), not self.field = x
│   ├─► Infinite loop → self.save() in on_update triggers on_update again
│   └─► Transaction broken → frappe.db.commit() in controller (DON'T)
│
├─► SUBMIT PHASE (before_submit / on_submit)
│   ├─► "Not allowed to submit" → DocType missing is_submittable = 1
│   ├─► Partial state → Validation in on_submit (too late, already submitted)
│   └─► Stock/GL failures → Entries fail but docstatus already = 1
│
├─► CANCEL PHASE (before_cancel / on_cancel)
│   ├─► "Cannot cancel: linked docs" → Check and handle linked documents
│   └─► Partial cleanup → One reversal fails, rest skipped
│
└─► PERMISSION PHASE (has_permission / get_list)
    ├─► "Not permitted" → has_permission returns None (should be True/False)
    ├─► get_list returns nothing → permission_query_conditions SQL error
    └─► SQL injection → User input in conditions without escape

Error Message → Cause → Fix Table

Error MessageCauseFix
NamingSeries is not setDocType uses naming_series but field is missingAdd naming_series field to DocType or set autoname in controller
DuplicateEntryErrorautoname generated non-unique nameUse naming_series with counter, or add hash suffix
Maximum recursion depth exceededself.save() called in validate/on_updateNEVER call self.save() in hooks; use self.db_set() in on_update
Not allowed to submitDocType lacks is_submittable = 1Enable "Is Submittable" in DocType settings
Cannot cancel: linked docs existSubmitted linked documents block cancellationCancel linked docs first, or use before_cancel to check
AttributeError: super()Missing super() call in overridden hookALWAYS call super().method_name() first in overrides
Value missing for: fieldController validate skipped parent logicEnsure super().validate() is called
frappe.db.commit() breaks transactionsManual commit in controller hookNEVER call frappe.db.commit() in controllers
Changes lost in on_updateSet self.field = x instead of self.db_set()Use self.db_set("field", value) after save hooks
NestedSet: root cannot be childParent set to itself or circular referenceValidate parent != self in validate, check lft/rgt
extend_doctype_class conflict [v16+]Multiple apps extend same class with conflicting methodsUse MRO-aware design, check method resolution order
has_permission returns wrong resultFunction returns None instead of True/FalseALWAYS return explicit True or False
permission_query_conditions SQL errorMalformed WHERE clause fragmentTest conditions string independently, use frappe.db.escape()

Critical Error Patterns

1. Autoname Failures
# ❌ WRONG — Setting name directly fails
class CustomDoc(Document):
    def autoname(self):
        self.name = f"DOC-{self.customer}"  # May cause DuplicateEntryError

# ✅ CORRECT — Use naming utilities
class CustomDoc(Document):
    def autoname(self):
        # Option 1: Naming series
        from frappe.model.naming import set_name_by_naming_series
        set_name_by_naming_series(self)

        # Option 2: Safe format with counter
        self.name = frappe.model.naming.make_autoname(
            f"DOC-.{self.customer}.-.####"
        )

        # Option 3: Hash for guaranteed uniqueness
        # Set autoname = "hash" in DocType JSON instead

Autoname options: naming_series, field:fieldname, format:PREFIX-{fieldname}-.####, hash, Prompt, or custom autoname() method.

2. Validate Loop: self.save() in Hooks
# ❌ WRONG — Infinite recursion
class SalesOrder(Document):
    def validate(self):
        self.calculate_totals()
        self.save()  # Triggers validate again → infinite loop!

    def on_update(self):
        self.status = "Updated"
        self.save()  # Triggers on_update again → infinite loop!

# ✅ CORRECT — Framework handles save; use db_set after save
class SalesOrder(Document):
    def validate(self):
        self.calculate_totals()
        # No save() — framework saves after validate completes

    def on_update(self):
        self.db_set("status", "Updated")  # Direct DB write, no trigger
3. on_submit Without is_submittable
# ❌ ERROR — "Not allowed to submit"
class MyDoc(Document):
    def on_submit(self):
        self.create_entries()
# This fails if DocType JSON lacks: "is_submittable": 1

# ✅ FIX — Enable in DocType definition
# In my_doc.json:
# { "is_submittable": 1 }
# Then before_submit and on_submit hooks work
4. Wrong Lifecycle Hook: Error Timing
# ❌ WRONG — Validation in on_submit (document already submitted!)
class SalesOrder(Document):
    def on_submit(self):
        if not self.has_stock():
            frappe.throw(_("Insufficient stock"))  # docstatus already = 1!

# ✅ CORRECT — ALWAYS validate in before_submit
class SalesOrder(Document):
    def before_submit(self):
        if not self.has_stock():
            frappe.throw(_("Insufficient stock"))  # Clean abort, stays Draft

    def on_submit(self):
        self.create_stock_entries()  # Only post-submit actions here

Transaction Rollback Rules by Hook:

Hookfrappe.throw() Effect
validate / before_saveFull rollback — document NOT saved
before_submitFull rollback — stays Draft
before_cancelFull rollback — stays Submitted
on_update / after_insertDocument IS saved — error shown but doc persists
on_submitdocstatus = 1 — error shown but ALREADY submitted
on_canceldocstatus = 2 — error shown but ALREADY cancelled
5. Missing super() in Overrides
# ❌ WRONG — Parent validation completely skipped
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder

class CustomSalesOrder(SalesOrder):
    def validate(self):
        # Parent validate() never runs! All ERPNext validations bypassed!
        self.custom_check()

# ✅ CORRECT — ALWAYS call super() first
class CustomSalesOrder(SalesOrder):
    def validate(self):
        super().validate()  # Run all parent validations first
        self.custom_check()  # Then add custom logic
6. extend_doctype_class [v16+]
# In hooks.py — v16+ preferred approach
extend_doctype_class = {
    "Sales Order": ["myapp.overrides.sales_order.SalesOrderMixin"]
}

# myapp/overrides/sales_order.py
class SalesOrderMixin:
    """Mixin class — extends, does not replace."""
    def validate(self):
        super().validate()  # ALWAYS call super — runs original + other mixins
        self.custom_validation()

Resolution order: class ExtendedSalesOrder(Mixin2, Mixin1, OriginalSalesOrder) — last mixin listed has highest priority.

7. Flags for Recursion Guard
# ❌ WRONG — on_update of linked doc triggers this doc's on_update
class SalesOrder(Document):
    def on_update(self):
        self.update_quotation()  # Quotation.on_update triggers back here

# ✅ CORRECT — Use flags to prevent recursion
class SalesOrder(Document):
    def on_update(self):
        if self.flags.get("skip_linked_update"):
            return
        self.flags.skip_linked_update = True
        self.update_quotation()

    def update_quotation(self):
        if self.quotation:
            q = frappe.get_doc("Quotation", self.quotation)
            q.flags.skip_linked_update = True  # Prevent back-trigger
            q.db_set("status", "Ordered")
8. get_list Permission Errors
# ❌ WRONG — permission_query_conditions returns None (fallback to no filter)
def get_permission_query(user):
    pass  # Returns None — shows ALL records!

# ❌ WRONG — SQL injection
def get_permission_query(user):
    dept = frappe.db.get_value("User", user, "department")
    return f"department = '{dept}'"  # INJECTION RISK

# ✅ CORRECT — Explicit conditions with escape
def get_permission_query(user):
    if "System Manager" in frappe.get_roles(user):
        return ""  # No filter — full access
    dept = frappe.db.get_value("User", user, "department")
    if dept:
        return f"department = {frappe.db.escape(dept)}"
    return "owner = {0}".format(frappe.db.escape(user))

Note: permission_query_conditions affects frappe.db.get_list() only, NOT frappe.db.get_all().

9. NestedSet Errors
# ❌ WRONG — Circular reference causes lft/rgt corruption
class Territory(NestedSet):
    def validate(self):
        # No parent validation!
        pass

# ✅ CORRECT — Validate parent chain
class Territory(NestedSet):
    def validate(self):
        super().validate()
        if self.parent_territory == self.name:
            frappe.throw(_("Territory cannot be its own parent"))
        # NestedSet.validate() checks circular refs automatically
        # but explicit check gives better error message

on_cancel: Isolate Cleanup Operations

# ❌ WRONG — First failure stops all cleanup
def on_cancel(self):
    self.reverse_stock()     # If this fails...
    self.reverse_gl()        # ...this never runs
    self.update_linked()     # ...neither does this

# ✅ CORRECT — Isolate each reversal
def on_cancel(self):
    errors = []
    for operation, label in [
        (self.reverse_stock, "Stock reversal"),
        (self.reverse_gl, "GL reversal"),
        (self.update_linked, "Linked docs"),
    ]:
        try:
            operation()
        except Exception as e:
            errors.append(f"{label}: {str(e)}")
            frappe.log_error(frappe.get_traceback(), f"{label} Error")

    if errors:
        frappe.msgprint(
            _("Cancelled with errors:<br>{0}").format("<br>".join(errors)),
            indicator="orange"
        )

ALWAYS / NEVER Rules

ALWAYS
  1. Call super().method() in overridden hooks — Preserve parent logic
  2. Validate in before_submit not on_submit — Last clean abort point
  3. Use self.db_set() in on_update — Direct self.field = x is lost
  4. Use self.flags for recursion guards — Prevent circular hook triggers
  5. Isolate cleanup operations in on_cancel — Don't let one failure stop all
  6. Use frappe.db.escape() in permission queries — Prevent SQL injection
  7. Return explicit True/False from has_permission — None falls back to default
  8. Use frappe.log_error() for unexpected exceptions — Never swallow silently
  9. Use _() wrapper for all user-facing error messages — Enable translation
NEVER
  1. NEVER call self.save() in validate/on_update — Causes infinite recursion
  2. NEVER call frappe.db.commit() in controllers — Framework manages transactions
  3. NEVER put blocking validation in on_submit — Document already submitted
  4. NEVER skip super() in overridden methods — Breaks parent class logic
  5. NEVER return None from has_permission — Returns unpredictable results
  6. NEVER swallow exceptions with bare except: pass — Always log errors
  7. NEVER use override_doctype_class when extend_doctype_class works [v16+]
  8. NEVER put heavy operations in validate — Use frappe.enqueue() from on_update

Reference Files

FileContents
references/examples.mdReal controller error scenarios with diagnosis
references/anti-patterns.mdCommon controller mistakes with fixes
references/patterns.mdDefensive error handling patterns by lifecycle hook

Comments

Markdown supported