frappe-errors-controllers · diff
v2.0 to v2.0
262 added, 356 removed. Audit A to A.
---
name: frappe-errors-controllers
description: >
- Use when handling errors in ERPNext Document Controllers. Covers
- try/except patterns, validation errors, permission errors, transaction
- management, rollback patterns, and error logging for v14/v15/v16.
- Keywords: controller error, try except, ValidationError, PermissionError,
- rollback, error handling.
+ Use when debugging or preventing errors in Frappe Document Controllers.
+ Prevents autoname failures, validate loops, on_submit without is_submittable,
+ wrong lifecycle hook choice, get_list permission errors, NestedSet errors,
+ extend_doctype_class conflicts, missing super() calls, and recursion without
+ flags. Covers error diagnosis by lifecycle phase for v14/v15/v16.
+ Keywords: controller error, autoname, validate loop, on_submit, is_submittable,
+ get_list, NestedSet, extend_doctype_class, super, flags, recursion guard.
license: MIT
compatibility: "Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16."
metadata:
author: OpenAEC-Foundation
version: "2.0"
---
- # ERPNext Controllers - Error Handling
-
- This skill covers error handling patterns for Document Controllers. For syntax, see `frappe-syntax-controllers`. For implementation workflows, see `frappe-impl-controllers`.
+ # Controller Errors — Diagnosis and Resolution
- **Version**: v14/v15/v16 compatible
+ Cross-refs: `frappe-syntax-controllers` (syntax), `frappe-impl-controllers` (workflows), `frappe-errors-serverscripts` (server scripts).
---
- ## Controllers vs Server Scripts: Error Handling
+ ## Error Diagnosis by Lifecycle Phase
```
- ┌─────────────────────────────────────────────────────────────────────┐
- │ CONTROLLERS HAVE FULL PYTHON POWER │
- ├─────────────────────────────────────────────────────────────────────┤
- │ │
- │ ✅ try/except blocks - Full exception handling │
- │ ✅ raise statements - Custom exceptions │
- │ ✅ Multiple except clauses - Handle specific errors │
- │ ✅ finally blocks - Cleanup operations │
- │ ✅ frappe.throw() - Stop with user message │
- │ ✅ frappe.log_error() - Silent error logging │
- │ │
- │ ⚠️ Transaction behavior varies by hook: │
- │ • validate: throw rolls back entire save │
- │ • on_update: document already saved! │
- │ • on_submit: partial rollback possible │
- │ │
- └─────────────────────────────────────────────────────────────────────┘
+ 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
```
---
- ## Main Decision: Error Handling by Hook
+ ## Error Message → Cause → Fix Table
- ```
- ┌─────────────────────────────────────────────────────────────────────────┐
- │ WHICH LIFECYCLE HOOK ARE YOU IN? │
- ├─────────────────────────────────────────────────────────────────────────┤
- │ │
- │ ► validate / before_save │
- │ └─► frappe.throw() → Rolls back, document NOT saved │
- │ └─► try/except → Catch and re-throw or handle gracefully │
- │ │
- │ ► on_update / after_insert │
- │ └─► Document already saved! frappe.throw() shows error but saved │
- │ └─► Use try/except + log_error for non-critical operations │
- │ └─► Critical failures: frappe.throw() (shows error, doc is saved) │
- │ │
- │ ► before_submit │
- │ └─► frappe.throw() → Prevents submit, stays draft │
- │ └─► Last chance for validation before docstatus=1 │
- │ │
- │ ► on_submit │
- │ └─► Document is submitted! throw shows error but docstatus=1 │
- │ └─► Critical: throw causes partial state (submitted but failed) │
- │ └─► Better: validate everything in before_submit │
- │ │
- │ ► on_cancel │
- │ └─► Reverse operations - use try/except for each reversal │
- │ └─► Log errors but try to continue cleanup │
- │ │
- └─────────────────────────────────────────────────────────────────────────┘
- ```
+ | Error Message | Cause | Fix |
+ |---------------|-------|-----|
+ | `NamingSeries is not set` | DocType uses naming_series but field is missing | Add `naming_series` field to DocType or set `autoname` in controller |
+ | `DuplicateEntryError` | `autoname` generated non-unique name | Use `naming_series` with counter, or add hash suffix |
+ | `Maximum recursion depth exceeded` | `self.save()` called in validate/on_update | NEVER call `self.save()` in hooks; use `self.db_set()` in on_update |
+ | `Not allowed to submit` | DocType lacks `is_submittable = 1` | Enable "Is Submittable" in DocType settings |
+ | `Cannot cancel: linked docs exist` | Submitted linked documents block cancellation | Cancel linked docs first, or use `before_cancel` to check |
+ | `AttributeError: super()` | Missing `super()` call in overridden hook | ALWAYS call `super().method_name()` first in overrides |
+ | `Value missing for: field` | Controller validate skipped parent logic | Ensure `super().validate()` is called |
+ | `frappe.db.commit() breaks transactions` | Manual commit in controller hook | NEVER call `frappe.db.commit()` in controllers |
+ | `Changes lost in on_update` | Set `self.field = x` instead of `self.db_set()` | Use `self.db_set("field", value)` after save hooks |
+ | `NestedSet: root cannot be child` | Parent set to itself or circular reference | Validate parent != self in validate, check `lft`/`rgt` |
+ | `extend_doctype_class conflict` [v16+] | Multiple apps extend same class with conflicting methods | Use MRO-aware design, check method resolution order |
+ | `has_permission returns wrong result` | Function returns None instead of True/False | ALWAYS return explicit True or False |
+ | `permission_query_conditions SQL error` | Malformed WHERE clause fragment | Test conditions string independently, use `frappe.db.escape()` |
---
- ## Error Methods Reference
+ ## Critical Error Patterns
- ### Quick Reference
+ ### 1. Autoname Failures
- | Method | Stops Execution? | Rolls Back? | User Sees? | Use For |
- |--------|:----------------:|:-----------:|:----------:|---------|
- | `frappe.throw()` | ✅ YES | Depends on hook | Dialog | Validation errors |
- | `raise Exception` | ✅ YES | Depends on hook | Error page | Internal errors |
- | `frappe.msgprint()` | ❌ NO | ❌ NO | Dialog | Warnings |
- | `frappe.log_error()` | ❌ NO | ❌ NO | Error Log | Debug/audit |
+ ```python
+ # ❌ WRONG — Setting name directly fails
+ class CustomDoc(Document):
+ def autoname(self):
+ self.name = f"DOC-{self.customer}" # May cause DuplicateEntryError
- ### Transaction Rollback by Hook
+ # ✅ 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)
- | Hook | frappe.throw() Effect |
- |------|----------------------|
- | `validate` | ✅ Full rollback - document NOT saved |
- | `before_save` | ✅ Full rollback - document NOT saved |
- | `on_update` | ⚠️ Document IS saved, error shown |
- | `after_insert` | ⚠️ Document IS saved, error shown |
- | `before_submit` | ✅ Full rollback - stays Draft |
- | `on_submit` | ⚠️ docstatus=1, error shown |
- | `before_cancel` | ✅ Full rollback - stays Submitted |
- | `on_cancel` | ⚠️ docstatus=2, error shown |
+ # 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
+ ```
- ## Error Handling Patterns
+ **Autoname options**: `naming_series`, `field:fieldname`, `format:PREFIX-{fieldname}-.####`, `hash`, `Prompt`, or custom `autoname()` method.
- ### Pattern 1: Validation with Error Collection
+ ### 2. Validate Loop — self.save() in Hooks
```python
- def validate(self):
- """Collect all errors before throwing."""
- errors = []
-
- # Required fields
- if not self.customer:
- errors.append(_("Customer is required"))
-
- if not self.items:
- errors.append(_("At least one item is required"))
-
- # Business rules
- if self.discount_percent > 50:
- errors.append(_("Discount cannot exceed 50%"))
-
- # Child table validation
- for idx, item in enumerate(self.items, 1):
- if not item.item_code:
- errors.append(_("Row {0}: Item Code is required").format(idx))
- if (item.qty or 0) <= 0:
- errors.append(_("Row {0}: Quantity must be positive").format(idx))
-
- # Throw all errors at once
- if errors:
- frappe.throw("<br>".join(errors), title=_("Validation Error"))
- ```
+ # ❌ WRONG — Infinite recursion
+ class SalesOrder(Document):
+ def validate(self):
+ self.calculate_totals()
+ self.save() # Triggers validate again → infinite loop!
- ### Pattern 2: External API Call with Fallback
+ def on_update(self):
+ self.status = "Updated"
+ self.save() # Triggers on_update again → infinite loop!
- ```python
- def validate(self):
- """Call external API with error handling."""
- if self.requires_credit_check:
- try:
- result = self.check_credit_external()
- self.credit_score = result.get("score", 0)
- except requests.Timeout:
- # Timeout - use cached value
- frappe.msgprint(
- _("Credit check timed out. Using cached value."),
- indicator="orange"
- )
- self.credit_score = self.get_cached_credit_score()
- except requests.RequestException as e:
- # API error - log and continue with warning
- frappe.log_error(
- f"Credit check failed: {str(e)}",
- "External API Error"
- )
- frappe.msgprint(
- _("Credit check unavailable. Please verify manually."),
- indicator="orange"
- )
- self.credit_check_pending = 1
- except Exception as e:
- # Unexpected error - log and re-raise
- frappe.log_error(frappe.get_traceback(), "Credit Check Error")
- frappe.throw(_("Credit check failed. Please try again."))
+ # ✅ 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
```
- ### Pattern 3: Safe Post-Save Operations
+ ### 3. on_submit Without is_submittable
```python
- def on_update(self):
- """Handle post-save operations safely."""
- # Critical operation - throw on failure
- self.update_linked_documents()
-
- # Non-critical operations - log errors, don't throw
- try:
- self.send_notification()
- except Exception:
- frappe.log_error(
- frappe.get_traceback(),
- f"Notification failed for {self.name}"
- )
-
- try:
- self.sync_to_external_system()
- except Exception:
- frappe.log_error(
- frappe.get_traceback(),
- f"External sync failed for {self.name}"
- )
- # Queue for retry
- frappe.enqueue(
- "myapp.tasks.retry_sync",
- doctype=self.doctype,
- name=self.name,
- queue="short"
- )
+ # ❌ 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
```
- ### Pattern 4: Submittable Document Error Handling
+ ### 4. Wrong Lifecycle Hook — Error Timing
```python
- def before_submit(self):
- """All validations that must pass before submit."""
- # Validate everything here - last chance to abort cleanly
- if not self.items:
- frappe.throw(_("Cannot submit without items"))
-
- if self.grand_total <= 0:
- frappe.throw(_("Total must be greater than zero"))
-
- # Check stock availability
- for item in self.items:
- available = get_stock_balance(item.item_code, item.warehouse)
- if available < item.qty:
- frappe.throw(
- _("Row {0}: Insufficient stock for {1}. Available: {2}").format(
- item.idx, item.item_code, available
- )
- )
+ # ❌ 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!
- def on_submit(self):
- """Post-submit actions - document is already submitted!"""
- # These operations should not fail if before_submit passed
- try:
- self.create_stock_ledger_entries()
- except Exception as e:
- # CRITICAL: Document is submitted but entries failed!
- frappe.log_error(frappe.get_traceback(), "Stock Ledger Error")
- frappe.throw(
- _("Stock entries failed. Please cancel and retry. Error: {0}").format(str(e))
- )
-
- try:
- self.create_gl_entries()
- except Exception as e:
- # Rollback stock entries if GL fails
- self.reverse_stock_ledger_entries()
- frappe.log_error(frappe.get_traceback(), "GL Entry Error")
- frappe.throw(_("Accounting entries failed. Stock entries reversed."))
+ # ✅ 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
```
- ### Pattern 5: Cancel with Cleanup
+ **Transaction Rollback Rules by Hook:**
+ | Hook | `frappe.throw()` Effect |
+ |------|------------------------|
+ | `validate` / `before_save` | Full rollback — document NOT saved |
+ | `before_submit` | Full rollback — stays Draft |
+ | `before_cancel` | Full rollback — stays Submitted |
+ | `on_update` / `after_insert` | Document IS saved — error shown but doc persists |
+ | `on_submit` | docstatus = 1 — error shown but ALREADY submitted |
+ | `on_cancel` | docstatus = 2 — error shown but ALREADY cancelled |
+
+ ### 5. Missing super() in Overrides
+
```python
- def before_cancel(self):
- """Validate cancel is allowed."""
- # Check for linked documents
- linked_invoices = frappe.get_all(
- "Sales Invoice Item",
- filters={"sales_order": self.name, "docstatus": 1},
- pluck="parent"
- )
-
- if linked_invoices:
- frappe.throw(
- _("Cannot cancel. Linked invoices exist: {0}").format(
- ", ".join(linked_invoices)
- )
- )
+ # ❌ WRONG — Parent validation completely skipped
+ from erpnext.selling.doctype.sales_order.sales_order import SalesOrder
- def on_cancel(self):
- """Reverse operations - try to complete all cleanup."""
- errors = []
-
- # Reverse stock
- try:
- self.reverse_stock_ledger_entries()
- except Exception as e:
- errors.append(f"Stock reversal: {str(e)}")
- frappe.log_error(frappe.get_traceback(), "Stock Reversal Error")
-
- # Reverse GL
- try:
- self.reverse_gl_entries()
- except Exception as e:
- errors.append(f"GL reversal: {str(e)}")
- frappe.log_error(frappe.get_traceback(), "GL Reversal Error")
-
- # Update linked docs
- try:
- self.update_linked_on_cancel()
- except Exception as e:
- errors.append(f"Linked docs: {str(e)}")
- frappe.log_error(frappe.get_traceback(), "Linked Doc Update Error")
-
- # Report any errors but don't prevent cancel
- if errors:
- frappe.msgprint(
- _("Cancel completed with errors:<br>{0}").format("<br>".join(errors)),
- title=_("Warning"),
- indicator="orange"
- )
+ 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
```
- ### Pattern 6: Database Operation Error Handling
+ ### 6. extend_doctype_class [v16+]
```python
- def validate(self):
- """Handle database errors gracefully."""
- try:
- # Check for duplicates
- existing = frappe.db.exists(
- "Customer Contract",
- {"customer": self.customer, "status": "Active", "name": ["!=", self.name]}
- )
- if existing:
- frappe.throw(_("Active contract already exists for this customer"))
-
- except frappe.db.InternalError as e:
- # Database error - log and show user-friendly message
- frappe.log_error(frappe.get_traceback(), "Database Error")
- frappe.throw(_("Database error. Please try again or contact support."))
- ```
-
- > **See**: `references/patterns.md` for more error handling patterns.
+ # 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()
+ ```
- ## Transaction Management
+ **Resolution order**: `class ExtendedSalesOrder(Mixin2, Mixin1, OriginalSalesOrder)` — last mixin listed has highest priority.
- ### Understanding Transactions
+ ### 7. Flags for Recursion Guard
```python
- # Frappe wraps each request in a transaction
- # - On success: auto-commit
- # - On exception: auto-rollback
+ # ❌ 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
- def validate(self):
- # All these changes are in ONE transaction
- self.calculate_totals()
- frappe.db.set_value("Counter", "main", "count", 100)
-
- if error_condition:
- frappe.throw("Error") # EVERYTHING rolls back
+ # ✅ 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 on_update(self):
- # Document save is already committed!
- # New changes here are in a NEW transaction
- frappe.db.set_value("Other", "doc", "field", "value")
-
- if error_condition:
- frappe.throw("Error") # Only on_update changes roll back
- # The document itself is already saved!
+ 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")
```
- ### Manual Savepoints (Advanced)
+ ### 8. get_list Permission Errors
```python
- def on_submit(self):
- """Use savepoints for partial rollback."""
- # Create savepoint before risky operation
- frappe.db.savepoint("before_stock")
-
- try:
- self.create_stock_entries()
- except Exception:
- # Rollback only stock entries
- frappe.db.rollback(save_point="before_stock")
- frappe.log_error(frappe.get_traceback(), "Stock Entry Error")
- frappe.throw(_("Stock entries failed"))
-
- frappe.db.savepoint("before_gl")
-
- try:
- self.create_gl_entries()
- except Exception:
- frappe.db.rollback(save_point="before_gl")
- frappe.log_error(frappe.get_traceback(), "GL Entry Error")
- frappe.throw(_("GL entries failed"))
- ```
+ # ❌ 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
- ## Critical Rules
+ # ✅ 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))
+ ```
- ### ✅ ALWAYS
+ **Note**: `permission_query_conditions` affects `frappe.db.get_list()` only, NOT `frappe.db.get_all()`.
- 1. **Collect multiple validation errors** - Better UX than one at a time
- 2. **Use try/except around external calls** - APIs, file I/O, network
- 3. **Log unexpected errors** - `frappe.log_error(frappe.get_traceback())`
- 4. **Call super() in overridden methods** - Preserve parent behavior
- 5. **Validate in before_submit** - Last clean abort point for submittables
- 6. **Use _() for error messages** - Enable translation
+ ### 9. NestedSet Errors
- ### ❌ NEVER
+ ```python
+ # ❌ WRONG — Circular reference causes lft/rgt corruption
+ class Territory(NestedSet):
+ def validate(self):
+ # No parent validation!
+ pass
- 1. **Don't call frappe.db.commit()** - Framework handles transactions
- 2. **Don't swallow errors silently** - Always log unexpected exceptions
- 3. **Don't assume on_update can rollback doc** - It's already saved
- 4. **Don't put critical logic in on_submit** - Validate in before_submit
- 5. **Don't ignore return values** - Check for None/empty results
+ # ✅ 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
+ ```
---
- ## Quick Reference: Exception Handling
+ ## on_cancel — Isolate Cleanup Operations
```python
- # Catch specific exceptions first, general last
- try:
- result = risky_operation()
- except frappe.ValidationError:
- # Re-raise validation errors
- raise
- except frappe.DoesNotExistError:
- # Handle missing document
- frappe.throw(_("Referenced document not found"))
- except requests.Timeout:
- # Handle timeout specifically
- frappe.msgprint(_("Operation timed out"), indicator="orange")
- except Exception as e:
- # Log and handle unexpected errors
- frappe.log_error(frappe.get_traceback(), "Unexpected Error")
- frappe.throw(_("An error occurred: {0}").format(str(e)))
+ # ❌ 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"
+ )
```
---
- ## Reference Files
+ ## ALWAYS / NEVER Rules
- | File | Contents |
- |------|----------|
- | `references/patterns.md` | Complete error handling patterns |
- | `references/examples.md` | Full working examples |
- | `references/anti-patterns.md` | Common mistakes to avoid |
+ ### 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`
+
---
- ## See Also
+ ## Reference Files
- - `frappe-syntax-controllers` - Controller syntax
- - `frappe-impl-controllers` - Implementation workflows
- - `frappe-errors-serverscripts` - Server Script error handling (sandbox)
- - `frappe-errors-hooks` - Hook error handling
+ | File | Contents |
+ |------|----------|
+ | `references/examples.md` | Real controller error scenarios with diagnosis |
+ | `references/anti-patterns.md` | Common controller mistakes with fixes |
+ | `references/patterns.md` | Defensive error handling patterns by lifecycle hook |