Claude-skill-registry erpnext-errors-hooks
Error handling patterns for ERPNext hooks.py configurations. Use when debugging doc_events errors, scheduler failures, boot session issues, and app initialization problems. V14/V15/V16 compatible. Triggers: hooks.py error, doc_events error, scheduler error, boot session error, app initialization error.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/erpnext-errors-hooks" ~/.claude/skills/majiayu000-claude-skill-registry-erpnext-errors-hooks && rm -rf "$T"
manifest:
skills/data/erpnext-errors-hooks/SKILL.mdsource content
ERPNext Hooks - Error Handling
This skill covers error handling patterns for hooks.py configurations. For syntax, see
erpnext-syntax-hooks. For implementation workflows, see erpnext-impl-hooks.
Version: v14/v15/v16 compatible
Hooks Error Handling Overview
┌─────────────────────────────────────────────────────────────────────┐ │ HOOKS HAVE UNIQUE ERROR HANDLING CHARACTERISTICS │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ✅ Full Python power (try/except, raise) │ │ ⚠️ Multiple handlers in chain - one failure affects others │ │ ⚠️ Some hooks are silent (scheduler, permission_query) │ │ ⚠️ Transaction behavior varies by hook type │ │ │ │ Key differences from controllers: │ │ • doc_events runs AFTER controller methods │ │ • Multiple apps can register handlers (order matters!) │ │ • Scheduler has NO user feedback - logging is critical │ │ • Permission hooks should NEVER throw errors │ │ │ └─────────────────────────────────────────────────────────────────────┘
Main Decision: Error Handling by Hook Type
┌─────────────────────────────────────────────────────────────────────────┐ │ WHICH HOOK TYPE ARE YOU USING? │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ► doc_events (validate, on_update, on_submit, etc.) │ │ └─► Same as controllers: frappe.throw() rolls back in validate │ │ └─► Multiple handlers: first error stops chain │ │ └─► Isolate non-critical operations in try/except │ │ │ │ ► scheduler_events (daily, hourly, cron) │ │ └─► NO user feedback - frappe.log_error() is essential │ │ └─► ALWAYS use try/except around operations │ │ └─► MUST call frappe.db.commit() manually │ │ │ │ ► permission_query_conditions │ │ └─► NEVER throw errors - return empty string on error │ │ └─► Silent failures break list views │ │ └─► Log errors but return safe fallback │ │ │ │ ► has_permission │ │ └─► NEVER throw errors - return False on error │ │ └─► Return None to defer to default permission │ │ │ │ ► override_doctype_class / extend_doctype_class │ │ └─► ALWAYS call super() in try/except │ │ └─► Parent errors should usually propagate │ │ │ │ ► extend_bootinfo │ │ └─► Errors break page load entirely! │ │ └─► ALWAYS wrap in try/except with fallback │ │ │ └─────────────────────────────────────────────────────────────────────────┘
doc_events Error Handling
Transaction Behavior (Same as Controllers)
| Event | frappe.throw() Effect |
|---|---|
| ✅ Full rollback - document NOT saved |
| ✅ Full rollback - document NOT saved |
| ⚠️ Document IS saved, error shown |
| ⚠️ Document IS saved, error shown |
| ⚠️ docstatus=1, error shown |
| ⚠️ docstatus=2, error shown |
Multiple Handler Chain
# hooks.py - Multiple apps can register handlers # App A doc_events = { "Sales Invoice": { "validate": "app_a.events.validate_si" # Runs first } } # App B doc_events = { "Sales Invoice": { "validate": "app_b.events.validate_si" # Runs second } } # If App A throws error, App B's handler NEVER runs!
Pattern: Validate Handler
# myapp/events/sales_invoice.py import frappe from frappe import _ def validate(doc, method=None): """Validate handler with proper error handling.""" errors = [] # Collect validation errors if doc.grand_total < 0: errors.append(_("Total cannot be negative")) if doc.custom_field and not doc.customer: errors.append(_("Customer required when custom field is set")) # Throw all at once if errors: frappe.throw("<br>".join(errors))
Pattern: on_update Handler (Isolated Operations)
def on_update(doc, method=None): """Post-save handler with isolated operations.""" # Critical operation - let errors propagate update_linked_records(doc) # Non-critical operations - isolate errors try: send_notification(doc) except Exception: frappe.log_error( frappe.get_traceback(), f"Notification failed for {doc.name}" ) try: sync_to_external(doc) except Exception: frappe.log_error( frappe.get_traceback(), f"External sync failed for {doc.name}" )
scheduler_events Error Handling
Critical: No User Feedback!
┌─────────────────────────────────────────────────────────────────────┐ │ ⚠️ SCHEDULER TASKS HAVE NO USER - LOGGING IS ESSENTIAL │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ • No one sees frappe.throw() - task just fails silently │ │ • No automatic email on failure (unless configured) │ │ • frappe.log_error() is your ONLY debugging tool │ │ • Always commit changes manually │ │ │ └─────────────────────────────────────────────────────────────────────┘
Pattern: Scheduler Task with Error Handling
# myapp/tasks.py import frappe def daily_sync(): """Daily sync task with comprehensive error handling.""" results = { "processed": 0, "errors": [] } try: # Get records to process (ALWAYS with limit!) records = frappe.get_all( "Sales Invoice", filters={"sync_status": "Pending"}, limit=500 ) for record in records: try: process_record(record.name) results["processed"] += 1 except Exception as e: results["errors"].append(f"{record.name}: {str(e)}") frappe.log_error( frappe.get_traceback(), f"Sync error: {record.name}" ) # REQUIRED: Commit changes frappe.db.commit() except Exception as e: # Log fatal errors frappe.log_error( frappe.get_traceback(), "Daily Sync Fatal Error" ) return # Log summary if results["errors"]: summary = f"Processed: {results['processed']}, Errors: {len(results['errors'])}" frappe.log_error( summary + "\n\n" + "\n".join(results["errors"][:50]), "Daily Sync Summary" )
Pattern: Scheduler with Batch Commits
def process_large_dataset(): """Process large dataset with periodic commits.""" BATCH_SIZE = 100 try: records = frappe.get_all("Item", limit=5000) total = len(records) for i in range(0, total, BATCH_SIZE): batch = records[i:i + BATCH_SIZE] for record in batch: try: update_item(record.name) except Exception: frappe.log_error( frappe.get_traceback(), f"Item update error: {record.name}" ) # Commit after each batch frappe.db.commit() except Exception: frappe.log_error(frappe.get_traceback(), "Batch Processing Error")
Permission Hooks Error Handling
permission_query_conditions - NEVER Throw!
# ❌ WRONG - Breaks list view entirely! def query_conditions(user): if not user: frappe.throw("User required") # DON'T DO THIS! return f"owner = '{user}'" # ✅ CORRECT - Return safe fallback def query_conditions(user): """Permission query with error handling.""" try: if not user: user = frappe.session.user if "System Manager" in frappe.get_roles(user): return "" # No restrictions return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}" except Exception: frappe.log_error( frappe.get_traceback(), "Permission Query Error" ) # Safe fallback - restrict to own records return f"`tabSales Invoice`.owner = {frappe.db.escape(frappe.session.user)}"
has_permission - NEVER Throw!
# ❌ WRONG - Breaks document access! def has_permission(doc, user=None, permission_type=None): if doc.status == "Locked": frappe.throw("Document is locked") # DON'T DO THIS! # ✅ CORRECT - Return boolean or None def has_permission(doc, user=None, permission_type=None): """Document permission check with error handling.""" try: user = user or frappe.session.user # Deny access to locked documents if doc.status == "Locked" and permission_type == "write": return False # Custom logic if permission_type == "delete": if doc.has_linked_records(): return False # Return None to defer to default permission system return None except Exception: frappe.log_error( frappe.get_traceback(), f"Permission check error: {doc.name}" ) # Safe fallback - defer to default return None
Override Hooks Error Handling
override_doctype_class
# myapp/overrides.py from erpnext.selling.doctype.sales_order.sales_order import SalesOrder import frappe from frappe import _ class CustomSalesOrder(SalesOrder): def validate(self): """Override with proper error handling.""" # ALWAYS call parent first in try/except try: super().validate() except frappe.ValidationError: # Re-raise validation errors raise except Exception as e: frappe.log_error(frappe.get_traceback(), "Parent Validate Error") raise # Custom validation self.custom_validate() def custom_validate(self): if self.custom_approval_required and not self.custom_approved: frappe.throw(_("Approval required before saving"))
extend_doctype_class (V16+)
# myapp/extends.py import frappe from frappe import _ class SalesOrderExtend: """Extension class - only add new methods.""" def custom_approval_check(self): """New method with error handling.""" try: if not self.custom_approver: frappe.throw(_("Approver not set")) approver = frappe.get_doc("User", self.custom_approver) if not approver.enabled: frappe.throw(_("Approver is disabled")) except frappe.DoesNotExistError: frappe.throw(_("Approver not found"))
extend_bootinfo Error Handling
Critical: Errors Break Page Load!
# ❌ WRONG - Unhandled error breaks desk entirely! def extend_boot(bootinfo): settings = frappe.get_single("My Settings") # What if it doesn't exist? bootinfo.my_config = settings.config # ✅ CORRECT - Always handle errors def extend_boot(bootinfo): """Extend bootinfo with error handling.""" try: if frappe.db.exists("My Settings", "My Settings"): settings = frappe.get_single("My Settings") bootinfo.my_config = settings.config or {} else: bootinfo.my_config = {} except Exception: frappe.log_error( frappe.get_traceback(), "Bootinfo Extension Error" ) # Safe fallback bootinfo.my_config = {}
Critical Rules
✅ ALWAYS
- Use try/except in scheduler tasks - No user feedback otherwise
- Call frappe.db.commit() in scheduler - Changes aren't auto-saved
- Return safe fallbacks in permission hooks - Never throw
- Call super() in override classes - Preserve parent behavior
- Log errors with context - Include document name, operation
- Wrap extend_bootinfo in try/except - Errors break page load
❌ NEVER
- Don't throw in permission_query_conditions - Breaks list views
- Don't throw in has_permission - Breaks document access
- Don't assume single handler - Multiple apps can register
- Don't commit in doc_events - Framework handles transactions
- Don't ignore scheduler errors - They fail silently
Quick Reference: Error Handling by Hook
| Hook Type | Can Throw? | Commit? | Key Pattern |
|---|---|---|---|
| doc_events (validate) | ✅ YES | ❌ NO | Collect errors, throw once |
| doc_events (on_update) | ⚠️ Careful | ❌ NO | Isolate non-critical ops |
| scheduler_events | ❌ Pointless | ✅ YES | Try/except + log_error |
| permission_query_conditions | ❌ NEVER | ❌ NO | Return "" on error |
| has_permission | ❌ NEVER | ❌ NO | Return None on error |
| extend_bootinfo | ❌ NEVER | ❌ NO | Try/except + fallback |
| override class | ✅ YES | ❌ NO | super() + re-raise |
Reference Files
| File | Contents |
|---|---|
| Complete error handling patterns |
| Full working examples |
| Common mistakes to avoid |
See Also
- Hooks syntaxerpnext-syntax-hooks
- Implementation workflowserpnext-impl-hooks
- Controller error handlingerpnext-errors-controllers
- Server Script error handlingerpnext-errors-serverscripts