
Frappe Errors Api
- 26 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with backend & apis tasks.
About
frappe-errors-api is a Claude Code skill for backend & apis. It helps solo builders move faster with AI-assisted development.
- frappe-errors-api
- Backend & APIs
- AI-coding skill
Frappe Errors Api by the numbers
- 26 all-time installs (skills.sh)
- Ranked #3,410 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-errors-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with backend & apis tasks.
Files
API Error Handling
For API implementation patterns see frappe-core-api. For permission errors see frappe-errors-permissions.
---
HTTP Status Code Map: Error -> Cause -> Fix
| Code | Frappe Exception | When It Happens | Fix |
|---|---|---|---|
| 200 | — | Success | — |
| 401 | AuthenticationError | Bad/expired token, wrong format | Check Authorization: token key:secret or Bearer access_token |
| 403 | PermissionError | Missing @whitelist, no role, no allow_guest | Add decorator or grant permission |
| 404 | DoesNotExistError | Wrong URL, doc not found, typo in endpoint path | Verify /api/resource/:doctype/:name or /api/method/dotted.path |
| 409 | DuplicateEntryError | Unique constraint violated | Check existing records before insert |
| 417 | ValidationError | frappe.throw() called | Fix validation logic or input data |
| 429 | RateLimitExceededError | Too many requests | Respect Retry-After header; throttle requests |
| 500 | Exception (unhandled) | Unhandled server error | Check Error Log; wrap in try/except |
| 503 | — | Server overloaded / maintenance | Retry with exponential backoff |
---
Authentication Errors (401)
Wrong Token Format
Error: HTTP 401 Unauthorized
Cause: Using "Bearer api_key:api_secret" instead of "token api_key:api_secret"Frappe uses TWO authentication formats — NEVER mix them:
| Method | Header Format | When to Use |
|---|---|---|
| API Key/Secret | Authorization: token api_key:api_secret | Server-to-server, scripts |
| OAuth Bearer | Authorization: Bearer access_token | OAuth 2.0 flows |
| Session Cookie | Cookie from /api/method/login | Browser-based apps |
# WRONG — Bearer with API key:secret
headers = {"Authorization": f"Bearer {api_key}:{api_secret}"}
# CORRECT — token keyword for API key:secret
headers = {"Authorization": f"token {api_key}:{api_secret}"}
# CORRECT — Bearer for OAuth access tokens only
headers = {"Authorization": f"Bearer {oauth_access_token}"}Expired OAuth Token
Error: HTTP 401 after token was working
Cause: OAuth access_token expired
Fix: Use refresh_token to get new access_tokendef get_fresh_token(settings):
"""ALWAYS implement token refresh for OAuth integrations."""
if is_token_expired(settings.token_expiry):
response = requests.post(f"{settings.base_url}/api/method/frappe.integrations.oauth2.get_token", data={
"grant_type": "refresh_token",
"refresh_token": settings.get_password("refresh_token"),
"client_id": settings.client_id,
})
if response.status_code == 200:
data = response.json()
settings.access_token = data["access_token"]
settings.token_expiry = frappe.utils.add_to_date(None, seconds=data["expires_in"])
settings.save(ignore_permissions=True)
else:
frappe.throw(_("OAuth token refresh failed"), exc=frappe.AuthenticationError)
return settings.access_token---
Forbidden Errors (403)
Missing @frappe.whitelist()
Error: HTTP 403 on /api/method/myapp.api.my_function
Cause: Function exists but lacks @frappe.whitelist() decorator
Fix: Add decorator — without it, NO external call is allowed# WRONG — Callable internally but returns 403 via REST
def my_function(name):
return frappe.get_doc("Item", name)
# CORRECT — Exposed to authenticated users
@frappe.whitelist()
def my_function(name):
return frappe.get_doc("Item", name)
# CORRECT — Exposed to everyone including unauthenticated
@frappe.whitelist(allow_guest=True)
def public_function():
return {"status": "ok"}Missing allow_guest for Public Endpoints
Error: HTTP 403 for unauthenticated requests
Cause: @frappe.whitelist() without allow_guest=True
Fix: Add allow_guest=True — but ALWAYS validate inputsNEVER use `allow_guest=True` without input validation — these endpoints are exposed to the internet.
---
Not Found Errors (404)
Common URL Mistakes
| Wrong URL | Correct URL | Issue |
|---|---|---|
/api/resource/SalesOrder/SO-001 | /api/resource/Sales Order/SO-001 | Space in DocType name |
/api/method/myapp.my_function | /api/method/myapp.api.my_function | Missing module path |
/api/resource/sales_order | /api/resource/Sales Order | Wrong case / underscore |
/api/v2/document/Item/ITEM-001 [v14] | /api/resource/Item/ITEM-001 | v2 API only in v15+ |
# ALWAYS URL-encode DocType names with spaces
import urllib.parse
url = f"/api/resource/{urllib.parse.quote('Sales Order')}/{name}"---
Validation Errors (417)
Every frappe.throw() call returns HTTP 417 by default (unless a specific exception class is provided).
# Returns 417 — generic validation error
frappe.throw(_("Amount must be positive"))
# Returns 417 — with explicit ValidationError type
frappe.throw(_("Amount must be positive"), exc=frappe.ValidationError)
# Returns 403 — PermissionError overrides to 403
frappe.throw(_("Access denied"), exc=frappe.PermissionError)
# Returns 404 — DoesNotExistError overrides to 404
frappe.throw(_("Not found"), exc=frappe.DoesNotExistError)ALWAYS use the specific exception class so clients can handle error types correctly:
# WRONG — all errors look the same to the client
frappe.throw(_("Customer not found")) # 417, generic
# CORRECT — client can distinguish 404 from validation error
frappe.throw(_("Customer not found"), exc=frappe.DoesNotExistError) # 404---
CSRF Token Errors
Error: HTTP 403 "CSRF token missing or invalid"
Cause: POST/PUT/DELETE request without X-Frappe-CSRF-Token headerRules:
- ALWAYS include
X-Frappe-CSRF-Tokenheader for session-based (cookie) auth. - Token-based auth (
Authorization: token ...) does NOT require CSRF token. - OAuth Bearer auth does NOT require CSRF token.
- The CSRF token is available in
frappe.csrf_tokenin JavaScript or embedded aswindow.CSRF_TOKEN.
// Browser-side: ALWAYS include CSRF for session-based requests
fetch("/api/method/myapp.api.update", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Frappe-CSRF-Token": frappe.csrf_token
},
body: JSON.stringify({data: "value"})
});---
CORS Errors
Error: "Access-Control-Allow-Origin" header missing
Cause: Cross-origin request not configured in site_config.json// site_config.json — NEVER use "*" in production
{
"allow_cors": "https://your-frontend.example.com"
}For multiple origins [v15+]:
{
"allow_cors": ["https://app1.example.com", "https://app2.example.com"]
}---
Rate Limit Errors (429)
Error: HTTP 429 Too Many Requests
Cause: Exceeded rate limit configured in site_config.json or hooks.py# hooks.py — rate limiting on whitelisted methods [v14+]
rate_limit = {"myapp.api.heavy_endpoint": {"limit": 10, "seconds": 60}}ALWAYS handle 429 in external API calls:
def call_with_rate_limit(url, data):
response = requests.post(url, json=data, timeout=30)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 60))
time.sleep(min(wait, 120)) # Cap at 2 minutes
response = requests.post(url, json=data, timeout=30)
response.raise_for_status()
return response.json()---
File Upload Errors
Error: HTTP 500 on /api/method/upload_file
Cause: Wrong content type, file too large, or missing file field# CORRECT file upload via REST API
import requests
response = requests.post(
f"{base_url}/api/method/upload_file",
headers={"Authorization": f"token {api_key}:{api_secret}"},
files={"file": ("document.pdf", open("document.pdf", "rb"), "application/pdf")},
data={
"doctype": "Sales Invoice",
"docname": "SINV-001",
"is_private": 1 # 1 = private, 0 = public
},
timeout=60 # ALWAYS set timeout for uploads
)Common upload failures:
Content-Typemust bemultipart/form-data(set automatically byfiles=param)- NEVER set
Content-Type: application/jsonfor file uploads - Check
max_file_sizein site_config.json (default 10MB) - [v15+]
allowed_file_extensionsrestricts file types
---
JSON Parse Errors
Error: "Failed to decode JSON" or unexpected behavior
Cause: API arguments sent as JSON string instead of parsed object@frappe.whitelist()
def update_items(items):
# ALWAYS handle both string and parsed input
if isinstance(items, str):
try:
items = frappe.parse_json(items)
except Exception:
frappe.throw(_("Invalid JSON format"), exc=frappe.ValidationError)
if not isinstance(items, (list, dict)):
frappe.throw(_("Expected list or dict"), exc=frappe.ValidationError)---
Webhook Delivery Failures
Error: Webhook not firing or returning errors
Cause: Target URL unreachable, wrong format, or timeoutDebug checklist: 1. Check Error Log for webhook delivery errors 2. Verify target URL is reachable from server 3. Check webhook condition — is it filtering out the event? 4. [v15+] Check Webhook Request Log for delivery status
# Custom webhook with error handling
@frappe.whitelist(allow_guest=True)
def incoming_webhook():
"""Handle incoming webhook with validation."""
payload = frappe.request.data
signature = frappe.request.headers.get("X-Webhook-Signature")
if not verify_signature(payload, signature):
frappe.local.response["http_status_code"] = 401
return {"error": "Invalid signature"}
try:
data = frappe.parse_json(payload)
except Exception:
frappe.local.response["http_status_code"] = 400
return {"error": "Invalid JSON payload"}
# ALWAYS return 200 quickly to prevent sender retries
frappe.enqueue(process_webhook_data, data=data, queue="short")
return {"status": "accepted"}---
Timeout on Long Operations
Error: HTTP 504 Gateway Timeout or connection reset
Cause: Operation takes longer than proxy/server timeout (typically 60s)Fix: Use background jobs for long operations:
@frappe.whitelist()
def start_long_operation(filters):
"""NEVER run long operations synchronously in API calls."""
job_id = frappe.generate_hash(length=10)
frappe.enqueue(
"myapp.tasks.run_long_operation",
queue="long",
timeout=600,
job_id=job_id,
filters=filters
)
return {"status": "queued", "job_id": job_id}
@frappe.whitelist()
def check_job_status(job_id):
"""Poll for job completion."""
from frappe.utils.background_jobs import get_info
jobs = get_info()
for job in jobs:
if job.get("job_id") == job_id:
return {"status": job.get("status", "unknown")}
return {"status": "completed"}---
Server-Side Error Pattern (Standard)
@frappe.whitelist()
def safe_api_endpoint(docname, action):
"""ALWAYS follow: validate -> check permission -> execute -> handle errors."""
# 1. Validate input
if not docname:
frappe.throw(_("Document name required"), exc=frappe.ValidationError)
# 2. Check existence
if not frappe.db.exists("My DocType", docname):
frappe.throw(_("Document not found"), exc=frappe.DoesNotExistError)
# 3. Check permission
frappe.has_permission("My DocType", "write", docname, throw=True)
# 4. Execute with error handling
try:
doc = frappe.get_doc("My DocType", docname)
result = doc.run_method(action)
return {"status": "success", "data": result}
except frappe.ValidationError:
raise # Let Frappe handle — returns 417
except frappe.PermissionError:
raise # Let Frappe handle — returns 403
except Exception:
frappe.log_error(frappe.get_traceback(), f"API Error: {docname}")
frappe.throw(_("Operation failed. Please try again."))---
Client-Side Error Handling
// ALWAYS handle errors in frappe.call
frappe.call({
method: "myapp.api.safe_api_endpoint",
args: {docname: "DOC-001", action: "approve"},
freeze: true,
freeze_message: __("Processing..."),
callback: function(r) {
if (r.message && r.message.status === "success") {
frappe.show_alert({message: __("Done"), indicator: "green"});
}
},
error: function(r) {
// ALWAYS check exc_type for specific handling
if (r.exc_type === "PermissionError") {
frappe.msgprint(__("You lack permission for this action."));
} else if (r.exc_type === "DoesNotExistError") {
frappe.msgprint(__("Record not found."));
} else if (!r.status) {
frappe.msgprint(__("Network error. Check your connection."));
}
}
});---
Critical Rules
ALWAYS
1. Use specific exception classes in frappe.throw() — enables correct HTTP status codes 2. Set timeout on all external requests — requests.get(url, timeout=30) 3. Validate ALL inputs before processing — whitelisted methods are callable by any logged-in user 4. Log errors before throwing — frappe.log_error() then frappe.throw() 5. Handle error callback in every frappe.call() — silent failures confuse users 6. Use background jobs for operations exceeding 30 seconds 7. Return 200 quickly from incoming webhooks then process asynchronously
NEVER
1. Expose internal errors to users — log traceback, show friendly message 2. Mix token formats — token key:secret vs Bearer oauth_token 3. Retry 4xx errors (except 429) — they indicate client bugs, not transient failures 4. Skip CSRF token for session-based POST requests — results in 403 5. Set Content-Type: application/json for file uploads — must be multipart/form-data 6. Catch exceptions without logging — makes production debugging impossible 7. Hardcode API credentials — use settings.get_password("field") from a DocType
---
Reference Files
| File | Contents |
|---|---|
references/patterns.md | Complete whitelisted method, webhook, external API patterns |
references/examples.md | Full working API module, client integration, external API client |
references/anti-patterns.md | 15 common API error handling mistakes |
---
See Also
frappe-core-api— API implementation patternsfrappe-errors-permissions— Permission error handling (403 deep dive)frappe-syntax-whitelisted— Whitelisted method syntaxfrappe-errors-serverscripts— Server Script error handling
Anti-Patterns — API Error Handling
Common mistakes to avoid. Each entry follows: WRONG -> CORRECT -> WHY.
---
1. No Input Validation
# WRONG — Uses inputs directly, crashes on bad data
@frappe.whitelist()
def process_order(customer, amount):
order = frappe.get_doc({"doctype": "Sales Order", "customer": customer,
"items": [{"item_code": "ITEM", "qty": 1, "rate": amount}]})
order.insert()
# CORRECT — Validate everything first
@frappe.whitelist()
def process_order(customer, amount):
if not customer:
frappe.throw(_("Customer required"), exc=frappe.ValidationError)
if not frappe.db.exists("Customer", customer):
frappe.throw(_("Customer not found"), exc=frappe.DoesNotExistError)
try:
amount = float(amount)
if amount <= 0:
frappe.throw(_("Amount must be positive"), exc=frappe.ValidationError)
except (ValueError, TypeError):
frappe.throw(_("Invalid amount"), exc=frappe.ValidationError)
# Now safe to proceedWhy: Unvalidated inputs cause cryptic errors and potential security issues.
---
2. Missing Error Callback in frappe.call
// WRONG — No feedback on failure
frappe.call({
method: "myapp.api.process",
args: {data: data},
callback: function(r) { frappe.show_alert("Done!"); }
// No error handler!
});
// CORRECT — ALWAYS handle errors
frappe.call({
method: "myapp.api.process",
args: {data: data},
callback: function(r) {
if (r.message) frappe.show_alert({message: "Done!", indicator: "green"});
},
error: function(r) {
frappe.msgprint({title: __("Error"),
message: r._server_messages || __("Operation failed"), indicator: "red"});
}
});Why: Without error callback, users see no feedback when API calls fail.
---
3. Exposing Internal Errors to Users
# WRONG — Leaks stack traces and internal details
@frappe.whitelist()
def calculate(item_code):
try:
return get_price(item_code)
except Exception as e:
frappe.throw(str(e)) # Exposes internals!
# CORRECT — Log internally, show friendly message
@frappe.whitelist()
def calculate(item_code):
try:
return get_price(item_code)
except Exception:
frappe.log_error(frappe.get_traceback(), "Price Calculation Error")
frappe.throw(_("Unable to calculate price. Please try again."))Why: Internal errors may expose database structure, file paths, or credentials.
---
4. No Permission Check in Whitelisted Method
# WRONG — Any logged-in user can delete any record
@frappe.whitelist()
def delete_record(doctype, name):
frappe.delete_doc(doctype, name)
# CORRECT — Check permission first
@frappe.whitelist()
def delete_record(doctype, name):
if not doctype or not name:
frappe.throw(_("DocType and name required"), exc=frappe.ValidationError)
frappe.has_permission(doctype, "delete", name, throw=True)
frappe.delete_doc(doctype, name)Why: @frappe.whitelist() makes the function callable by ANY logged-in user. Permission checks are mandatory.
---
5. Retrying 4xx Client Errors
# WRONG — Retries all errors including 400, 401, 403
def call_api(url, data):
for attempt in range(3):
response = requests.post(url, json=data)
if response.status_code != 200:
time.sleep(2 ** attempt)
continue
return response.json()
# CORRECT — Only retry 5xx and 429, NEVER retry other 4xx
def call_api(url, data):
for attempt in range(3):
response = requests.post(url, json=data, timeout=30)
if response.status_code == 200:
return response.json()
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", 60)))
continue
if 400 <= response.status_code < 500:
frappe.throw(f"Client error: {response.status_code}")
if response.status_code >= 500:
time.sleep(2 ** attempt)
continueWhy: 4xx errors indicate client bugs — retrying them wastes resources and never succeeds.
---
6. Swallowing Errors Silently
# WRONG — Silent failure, impossible to debug
@frappe.whitelist()
def sync_data():
try:
perform_sync()
except Exception:
pass # No logging!
# CORRECT — ALWAYS log before handling
@frappe.whitelist()
def sync_data():
try:
perform_sync()
return {"status": "success"}
except Exception:
frappe.log_error(frappe.get_traceback(), "Sync Error")
frappe.throw(_("Sync failed. Please try again."))Why: Silent failures make production debugging impossible. ALWAYS log with frappe.log_error().
---
7. Hardcoded API Credentials
# WRONG — Credentials in source code
def get_data():
headers = {"Authorization": "Bearer sk_live_abc123"}
return requests.get(url, headers=headers)
# CORRECT — Store in encrypted DocType field
def get_data():
settings = frappe.get_single("API Settings")
headers = {"Authorization": f"Bearer {settings.get_password('api_key')}"}
return requests.get(url, headers=headers, timeout=30)Why: Hardcoded credentials end up in version control and cannot be rotated.
---
8. No Timeout on External Requests
# WRONG — Can hang forever
response = requests.get("https://api.example.com/data")
# CORRECT — ALWAYS set timeout
response = requests.get("https://api.example.com/data", timeout=30)Why: Requests without timeout can hang indefinitely, blocking the worker process.
---
9. Wrong HTTP Status for Error Type
# WRONG — Returns 200 with error in body
@frappe.whitelist()
def get_item(name):
if not frappe.db.exists("Item", name):
return {"error": "Not found"} # Client gets 200!
# CORRECT — Use proper exception for 404
@frappe.whitelist()
def get_item(name):
if not frappe.db.exists("Item", name):
frappe.throw(_("Item not found"), exc=frappe.DoesNotExistError)
return frappe.get_doc("Item", name)Why: Proper HTTP status codes let clients handle different error types correctly.
---
10. Not Parsing JSON Input
# WRONG — Crashes if items arrives as JSON string
@frappe.whitelist()
def update_items(items):
for item in items: # TypeError if items is a string
update_item(item)
# CORRECT — Handle both string and parsed input
@frappe.whitelist()
def update_items(items):
if isinstance(items, str):
try:
items = frappe.parse_json(items)
except Exception:
frappe.throw(_("Invalid JSON"), exc=frappe.ValidationError)
if not isinstance(items, list):
frappe.throw(_("Items must be a list"), exc=frappe.ValidationError)
for item in items:
update_item(item)Why: frappe.call sends complex args as JSON strings. ALWAYS handle both formats.
---
11. No Loading Indicator for Long Operations
// WRONG — UI appears frozen
async function processLarge() {
const result = await frappe.xcall("myapp.api.process_large");
console.log(result);
}
// CORRECT — Show feedback during processing
async function processLarge() {
try {
frappe.freeze(__("Processing..."));
const result = await frappe.xcall("myapp.api.process_large");
frappe.show_alert({message: __("Complete!"), indicator: "green"});
return result;
} catch (e) {
frappe.msgprint({title: __("Error"), message: e.message, indicator: "red"});
} finally {
frappe.unfreeze();
}
}Why: Users need visual feedback during operations longer than 1 second.
---
12. No Rate Limit Handling
# WRONG — Rapid-fire requests hit rate limits
for record in records:
call_external_api(record) # Will get 429 eventually
# CORRECT — Throttle and handle 429
for i, record in enumerate(records):
try:
call_external_api(record)
except RateLimitError as e:
time.sleep(e.retry_after or 60)
call_external_api(record)
if i % 10 == 0:
time.sleep(1) # Throttle proactivelyWhy: All APIs have rate limits. Exceeding them causes cascading failures.
---
13. Inconsistent Error Response Format
# WRONG — Every endpoint returns errors differently
def ep1(): return {"error": True, "msg": "Failed"}
def ep2(): return {"success": False, "message": "Error"}
def ep3(): frappe.throw("Something wrong")
# CORRECT — Consistent: use frappe.throw with exception class
def ep1(): frappe.throw(_("Failed"), exc=frappe.ValidationError)
def ep2(): frappe.throw(_("Error"), exc=frappe.ValidationError)
def ep3(): frappe.throw(_("Something wrong"), exc=frappe.ValidationError)Why: Consistent error format simplifies client-side error handling across all endpoints.
---
14. Mixing Authentication Token Formats
# WRONG — Bearer with API key:secret
headers = {"Authorization": f"Bearer {api_key}:{api_secret}"}
# CORRECT — "token" keyword for API key:secret
headers = {"Authorization": f"token {api_key}:{api_secret}"}
# CORRECT — "Bearer" for OAuth tokens only
headers = {"Authorization": f"Bearer {oauth_access_token}"}Why: Frappe uses token prefix for API keys and Bearer for OAuth. Mixing them gives 401.
---
15. Processing Webhooks Synchronously
# WRONG — Slow processing causes sender timeouts and retries
@frappe.whitelist(allow_guest=True)
def webhook():
data = frappe.parse_json(frappe.request.data)
process_heavy_operation(data) # Takes 30+ seconds
return {"status": "ok"}
# CORRECT — Return 200 immediately, process in background
@frappe.whitelist(allow_guest=True)
def webhook():
data = frappe.parse_json(frappe.request.data)
frappe.enqueue("myapp.tasks.process_webhook", queue="short", data=data)
return {"status": "accepted"}Why: Webhook senders expect fast responses (< 5s). Slow responses trigger retries.
---
Checklist Before Deploying API Endpoints
- [ ] All inputs validated before use
- [ ] Permission checks in every whitelisted method
- [ ] Specific exception types (ValidationError, PermissionError, DoesNotExistError)
- [ ] Error callback in every frappe.call
- [ ] Internal errors logged, not exposed to users
- [ ] No hardcoded credentials
- [ ] Timeouts on all external requests
- [ ] Rate limiting handled (respect Retry-After)
- [ ] JSON inputs parsed safely (string or parsed)
- [ ] Network errors handled separately from server errors
- [ ] Loading indicators for long operations
- [ ] Consistent error response format
- [ ] Token format correct (token vs Bearer)
- [ ] CSRF token included for session-based auth
- [ ] Webhooks processed asynchronously
Examples — API Error Handling
Complete working examples for Frappe API error handling.
---
Example 1: Complete REST API Module
# myapp/api.py
"""
Order management API with comprehensive error handling.
ALWAYS follow: validate -> exists -> permission -> business -> execute.
"""
import frappe
from frappe import _
@frappe.whitelist()
def create_sales_order(customer, items, delivery_date=None):
"""Create Sales Order via API."""
# Validate customer
if not customer:
frappe.throw(_("Customer is required"), exc=frappe.ValidationError)
if not frappe.db.exists("Customer", customer):
frappe.throw(_("Customer not found"), exc=frappe.DoesNotExistError)
# Parse and validate items
if not items:
frappe.throw(_("At least one item required"), exc=frappe.ValidationError)
if isinstance(items, str):
try:
items = frappe.parse_json(items)
except Exception:
frappe.throw(_("Invalid items JSON"), exc=frappe.ValidationError)
for i, item in enumerate(items):
if not item.get("item_code"):
frappe.throw(_("Item {0}: item_code required").format(i + 1),
exc=frappe.ValidationError)
if not frappe.db.exists("Item", item["item_code"]):
frappe.throw(_("Item '{0}' not found").format(item["item_code"]),
exc=frappe.DoesNotExistError)
qty = item.get("qty", 0)
if not qty or qty <= 0:
frappe.throw(_("Item {0}: qty must be > 0").format(i + 1),
exc=frappe.ValidationError)
# Permission
frappe.has_permission("Sales Order", "create", throw=True)
# Create
try:
order = frappe.get_doc({
"doctype": "Sales Order",
"customer": customer,
"delivery_date": delivery_date or frappe.utils.add_days(frappe.utils.today(), 7),
"items": [{"item_code": it["item_code"], "qty": it["qty"],
"rate": it.get("rate", 0)} for it in items]
})
order.insert()
return {"status": "success", "order_name": order.name,
"grand_total": order.grand_total}
except frappe.DuplicateEntryError:
frappe.throw(_("Duplicate order detected"), exc=frappe.DuplicateEntryError)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Order creation failed: {customer}")
frappe.throw(_("Failed to create order. Please try again."))
@frappe.whitelist()
def get_order_status(order_name):
"""Get order status with permission-filtered fields."""
if not order_name:
frappe.throw(_("Order name required"), exc=frappe.ValidationError)
if not frappe.db.exists("Sales Order", order_name):
frappe.throw(_("Order not found"), exc=frappe.DoesNotExistError)
frappe.has_permission("Sales Order", "read", order_name, throw=True)
order = frappe.get_doc("Sales Order", order_name)
result = {
"name": order.name, "customer": order.customer,
"status": order.status, "docstatus": order.docstatus,
"items_count": len(order.items)
}
# Financial data only for authorized roles
financial_roles = ["Accounts User", "Accounts Manager", "Sales Manager", "System Manager"]
if any(r in frappe.get_roles() for r in financial_roles):
result.update({"grand_total": order.grand_total,
"taxes": order.total_taxes_and_charges})
return result
@frappe.whitelist()
def cancel_order(order_name, reason=None):
"""Cancel order with business validation."""
if not order_name:
frappe.throw(_("Order name required"), exc=frappe.ValidationError)
if not frappe.db.exists("Sales Order", order_name):
frappe.throw(_("Order not found"), exc=frappe.DoesNotExistError)
order = frappe.get_doc("Sales Order", order_name)
if not order.has_permission("cancel"):
frappe.throw(_("No cancel permission"), exc=frappe.PermissionError)
if order.docstatus != 1:
frappe.throw(_("Only submitted orders can be cancelled"), exc=frappe.ValidationError)
if order.per_delivered > 0:
frappe.throw(_("Cancel deliveries first"), exc=frappe.ValidationError)
try:
order.cancel()
if reason:
frappe.get_doc({"doctype": "Comment", "comment_type": "Info",
"reference_doctype": "Sales Order", "reference_name": order_name,
"content": f"Cancelled: {reason}"}).insert(ignore_permissions=True)
return {"status": "success", "message": _("Order cancelled")}
except Exception:
frappe.log_error(frappe.get_traceback(), f"Cancel error: {order_name}")
frappe.throw(_("Cancel failed: {0}").format(str(e)))
@frappe.whitelist()
def bulk_update_orders(order_names, updates):
"""Bulk update with per-document permission checks."""
if isinstance(order_names, str):
order_names = frappe.parse_json(order_names)
if isinstance(updates, str):
updates = frappe.parse_json(updates)
if not order_names:
frappe.throw(_("No orders specified"), exc=frappe.ValidationError)
if not updates:
frappe.throw(_("No updates specified"), exc=frappe.ValidationError)
# Whitelist allowed fields
allowed = {"delivery_date", "po_no", "customer_address"}
invalid = set(updates.keys()) - allowed
if invalid:
frappe.throw(_("Cannot update: {0}").format(", ".join(invalid)),
exc=frappe.ValidationError)
results = {"success": [], "failed": [], "permission_denied": [], "not_found": []}
for name in order_names:
if not frappe.db.exists("Sales Order", name):
results["not_found"].append(name)
continue
if not frappe.has_permission("Sales Order", "write", name):
results["permission_denied"].append(name)
continue
try:
for field, value in updates.items():
frappe.db.set_value("Sales Order", name, field, value)
results["success"].append(name)
except Exception as e:
results["failed"].append({"name": name, "error": str(e)})
frappe.db.commit()
return results---
Example 2: Client-Side API Integration
// myapp/public/js/order_api.js
const OrderAPI = {
async create(customer, items, deliveryDate) {
return this._call("myapp.api.create_sales_order",
{customer, items, delivery_date: deliveryDate},
__("Creating order..."));
},
async getStatus(orderName) {
return this._call("myapp.api.get_order_status",
{order_name: orderName}, __("Loading..."));
},
async cancel(orderName, reason) {
return this._call("myapp.api.cancel_order",
{order_name: orderName, reason}, __("Cancelling..."));
},
async bulkUpdate(orderNames, updates) {
return this._call("myapp.api.bulk_update_orders",
{order_names: orderNames, updates}, __("Updating..."));
},
_call(method, args, freezeMsg) {
return new Promise((resolve, reject) => {
frappe.call({
method, args, freeze: true, freeze_message: freezeMsg,
callback: (r) => resolve(r.message),
error: (r) => {
this._handleError(r);
reject(r);
}
});
});
},
_handleError(error) {
let title = __("Error"), message = __("An error occurred"), indicator = "red";
// Extract server messages
if (error._server_messages) {
try {
const msgs = JSON.parse(error._server_messages);
if (msgs.length > 0) {
const msg = JSON.parse(msgs[0]);
message = msg.message || msg;
}
} catch (e) {}
}
switch (error.exc_type) {
case "ValidationError": title = __("Validation Error"); break;
case "PermissionError": title = __("Permission Denied"); break;
case "DoesNotExistError": title = __("Not Found"); break;
case "DuplicateEntryError": title = __("Duplicate"); indicator = "orange"; break;
}
if (!error.status) {
title = __("Network Error");
message = __("Unable to connect to server");
indicator = "orange";
}
frappe.msgprint({title, message, indicator});
}
};
// Form integration
frappe.ui.form.on("Sales Order", {
refresh(frm) {
if (frm.doc.docstatus === 1) {
frm.add_custom_button(__("Cancel with Reason"), async function() {
const reason = await new Promise((resolve) => {
frappe.prompt({fieldname: "reason", fieldtype: "Small Text",
label: __("Reason"), reqd: 1},
(v) => resolve(v.reason));
});
try {
await OrderAPI.cancel(frm.doc.name, reason);
frappe.show_alert({message: __("Cancelled"), indicator: "green"});
frm.reload_doc();
} catch (e) { /* error already handled */ }
}, __("Actions"));
}
}
});---
Example 3: External Shipping API Integration
# myapp/integrations/shipping.py
import frappe
from frappe import _
import requests
import time
class ShippingAPIError(Exception):
pass
class ShippingAPI:
def __init__(self):
self.settings = frappe.get_single("Shipping Settings")
self.base_url = self.settings.api_url.rstrip("/")
self.api_key = self.settings.get_password("api_key")
self.timeout = 30
self.max_retries = 3
def create_shipment(self, delivery_note_name):
if not frappe.db.exists("Delivery Note", delivery_note_name):
frappe.throw(_("Delivery Note not found"), exc=frappe.DoesNotExistError)
dn = frappe.get_doc("Delivery Note", delivery_note_name)
if not dn.shipping_address_name:
frappe.throw(_("Shipping address required"), exc=frappe.ValidationError)
payload = self._build_payload(dn)
try:
result = self._post("/shipments", payload)
frappe.db.set_value("Delivery Note", delivery_note_name, {
"tracking_number": result["tracking_number"],
"shipping_label_url": result.get("label_url")})
frappe.db.commit()
return result
except ShippingAPIError as e:
frappe.log_error(str(e), f"Shipping error: {delivery_note_name}")
frappe.throw(str(e))
def _post(self, endpoint, data):
return self._request("POST", endpoint, json=data)
def _get(self, endpoint):
return self._request("GET", endpoint)
def _request(self, method, endpoint, **kwargs):
url = f"{self.base_url}{endpoint}"
headers = {"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"}
last_error = None
for attempt in range(self.max_retries):
try:
resp = requests.request(method=method, url=url, headers=headers,
timeout=self.timeout, **kwargs)
if resp.status_code in (200, 201):
return resp.json()
if resp.status_code == 401:
raise ShippingAPIError(_("Auth failed. Check API key."))
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", 60))
time.sleep(min(wait, 120))
continue
if 400 <= resp.status_code < 500:
raise ShippingAPIError(self._parse_error(resp))
if resp.status_code >= 500:
last_error = f"Server error {resp.status_code}"
time.sleep(2 ** attempt)
continue
except requests.exceptions.Timeout:
last_error = "Timeout"
time.sleep(2 ** attempt)
except requests.exceptions.ConnectionError:
last_error = "Connection failed"
time.sleep(2 ** attempt)
except ShippingAPIError:
raise
raise ShippingAPIError(_("Service unavailable: {0}").format(last_error))
def _parse_error(self, resp):
try:
data = resp.json()
return data.get("error", {}).get("message") or data.get("message") or str(data)
except Exception:
return resp.text[:200]
def _build_payload(self, dn):
addr = frappe.get_doc("Address", dn.shipping_address_name)
return {
"reference": dn.name,
"recipient": {"name": dn.customer_name, "address_line1": addr.address_line1,
"city": addr.city, "postal_code": addr.pincode, "country": addr.country},
"packages": [{"weight": it.total_weight or 1, "description": it.item_name}
for it in dn.items]
}
# Whitelisted endpoints
@frappe.whitelist()
def create_shipment(delivery_note):
return ShippingAPI().create_shipment(delivery_note)
@frappe.whitelist()
def track_shipment(tracking_number):
if not tracking_number:
frappe.throw(_("Tracking number required"), exc=frappe.ValidationError)
return ShippingAPI()._get(f"/tracking/{tracking_number}")---
Example 4: File Upload Endpoint
@frappe.whitelist()
def upload_attachment(doctype, docname):
"""Handle file upload with validation."""
if not doctype or not docname:
frappe.throw(_("DocType and name required"), exc=frappe.ValidationError)
if not frappe.db.exists(doctype, docname):
frappe.throw(_("Document not found"), exc=frappe.DoesNotExistError)
frappe.has_permission(doctype, "write", docname, throw=True)
files = frappe.request.files
if not files or "file" not in files:
frappe.throw(_("No file provided"), exc=frappe.ValidationError)
uploaded = files["file"]
# Validate file size (10 MB max)
max_size = 10 * 1024 * 1024
uploaded.seek(0, 2)
size = uploaded.tell()
uploaded.seek(0)
if size > max_size:
frappe.throw(_("File too large (max 10 MB)"), exc=frappe.ValidationError)
# Validate extension
allowed_ext = {".pdf", ".png", ".jpg", ".jpeg", ".doc", ".docx", ".xlsx"}
import os
ext = os.path.splitext(uploaded.filename)[1].lower()
if ext not in allowed_ext:
frappe.throw(_("File type not allowed: {0}").format(ext),
exc=frappe.ValidationError)
# Save via Frappe file handler
try:
file_doc = frappe.get_doc({
"doctype": "File",
"file_name": uploaded.filename,
"content": uploaded.read(),
"attached_to_doctype": doctype,
"attached_to_name": docname,
"is_private": 1
})
file_doc.insert(ignore_permissions=True)
return {"status": "success", "file_url": file_doc.file_url}
except Exception:
frappe.log_error(frappe.get_traceback(), "File Upload Error")
frappe.throw(_("Upload failed. Please try again."))---
Quick Reference
# Server-side error throwing with correct HTTP status
frappe.throw(_("Bad input"), exc=frappe.ValidationError) # 417
frappe.throw(_("Not found"), exc=frappe.DoesNotExistError) # 404
frappe.throw(_("Forbidden"), exc=frappe.PermissionError) # 403
frappe.throw(_("Duplicate"), exc=frappe.DuplicateEntryError) # 409
# Client-side error type checking
# r.exc_type === "ValidationError"
# r.exc_type === "PermissionError"
# r.exc_type === "DoesNotExistError"
# !r.status → network errorAPI Error Handling Patterns
Complete error handling patterns for Frappe API development. For quick reference see SKILL.md.
---
Pattern 1: Complete Whitelisted Method with Validation Pipeline
# myapp/api.py
import frappe
from frappe import _
@frappe.whitelist()
def process_payment(invoice_name, payment_method, amount):
"""
ALWAYS follow this order: validate -> exists -> permission -> business -> execute.
HTTP Status Codes:
200: Success
400/417: Validation error (frappe.ValidationError)
403: Permission denied (frappe.PermissionError)
404: Not found (frappe.DoesNotExistError)
500: Unhandled server error
"""
# ── 1. INPUT VALIDATION ──
errors = []
if not invoice_name:
errors.append(_("Invoice name is required"))
if not payment_method:
errors.append(_("Payment method is required"))
valid_methods = ["Cash", "Card", "Bank Transfer", "Check"]
if payment_method and payment_method not in valid_methods:
errors.append(_("Invalid payment method: {0}").format(payment_method))
try:
amount = float(amount) if amount else 0
if amount <= 0:
errors.append(_("Amount must be greater than zero"))
except (ValueError, TypeError):
errors.append(_("Invalid amount format"))
if errors:
frappe.throw("<br>".join(errors), exc=frappe.ValidationError)
# ── 2. EXISTENCE CHECK ──
if not frappe.db.exists("Sales Invoice", invoice_name):
frappe.throw(_("Sales Invoice {0} not found").format(invoice_name),
exc=frappe.DoesNotExistError)
# ── 3. PERMISSION CHECK ──
frappe.has_permission("Payment Entry", "create", throw=True)
# ── 4. BUSINESS VALIDATION ──
invoice = frappe.get_doc("Sales Invoice", invoice_name)
if invoice.docstatus != 1:
frappe.throw(_("Invoice must be submitted before payment"),
exc=frappe.ValidationError)
if amount > invoice.outstanding_amount:
frappe.throw(_("Amount ({0}) exceeds outstanding ({1})").format(
frappe.format_value(amount, {"fieldtype": "Currency"}),
frappe.format_value(invoice.outstanding_amount, {"fieldtype": "Currency"})),
exc=frappe.ValidationError)
# ── 5. EXECUTE WITH ERROR HANDLING ──
try:
payment = frappe.get_doc({
"doctype": "Payment Entry",
"payment_type": "Receive",
"party_type": "Customer",
"party": invoice.customer,
"paid_amount": amount,
"received_amount": amount,
"mode_of_payment": payment_method,
"references": [{
"reference_doctype": "Sales Invoice",
"reference_name": invoice_name,
"allocated_amount": amount
}]
})
payment.insert()
payment.submit()
return {
"status": "success",
"payment_entry": payment.name,
"message": _("Payment recorded successfully")
}
except frappe.DuplicateEntryError:
frappe.throw(_("Duplicate payment detected"), exc=frappe.DuplicateEntryError)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Payment Error: {invoice_name}")
frappe.throw(_("Payment failed. Please try again."))---
Pattern 2: API Response Wrapper Decorator
# myapp/api_utils.py
import frappe
from frappe import _
from functools import wraps
def api_response(func):
"""
Decorator for consistent API responses across all endpoints.
Success: {"status": "success", "data": ...}
Error: {"status": "error", "type": "...", "message": "..."}
"""
@wraps(func)
def wrapper(*args, **kwargs):
try:
result = func(*args, **kwargs)
return {"status": "success", "data": result}
except frappe.ValidationError as e:
frappe.local.response["http_status_code"] = 400
return {"status": "error", "type": "ValidationError", "message": str(e)}
except frappe.PermissionError as e:
frappe.local.response["http_status_code"] = 403
return {"status": "error", "type": "PermissionError",
"message": str(e) or _("Permission denied")}
except frappe.DoesNotExistError as e:
frappe.local.response["http_status_code"] = 404
return {"status": "error", "type": "NotFound",
"message": str(e) or _("Resource not found")}
except frappe.DuplicateEntryError as e:
frappe.local.response["http_status_code"] = 409
return {"status": "error", "type": "Conflict",
"message": str(e) or _("Duplicate entry")}
except Exception:
frappe.log_error(frappe.get_traceback(), "API Error")
frappe.local.response["http_status_code"] = 500
return {"status": "error", "type": "ServerError",
"message": _("An unexpected error occurred")}
return wrapper
# Usage
@frappe.whitelist()
@api_response
def get_customer_orders(customer):
if not customer:
raise frappe.ValidationError(_("Customer is required"))
if not frappe.db.exists("Customer", customer):
raise frappe.DoesNotExistError(_("Customer not found"))
return frappe.get_list("Sales Order",
filters={"customer": customer},
fields=["name", "transaction_date", "grand_total", "status"])---
Pattern 3: External API Client with Retry
# myapp/integrations/api_client.py
import frappe
from frappe import _
import requests
import time
class ExternalAPIClient:
"""External API client with retry, rate limit, and error handling."""
def __init__(self, settings_doctype="External API Settings"):
self.settings = frappe.get_single(settings_doctype)
self.base_url = self.settings.base_url.rstrip("/")
self.max_retries = 3
self.timeout = 30
def _get_headers(self):
return {
"Authorization": f"Bearer {self.settings.get_password('api_key')}",
"Content-Type": "application/json",
"Accept": "application/json"
}
def request(self, method, endpoint, data=None, params=None):
"""
Make request with retry logic.
NEVER retry 4xx errors (except 429). ALWAYS retry 5xx and timeouts.
"""
url = f"{self.base_url}/{endpoint.lstrip('/')}"
last_error = None
for attempt in range(self.max_retries):
try:
response = requests.request(
method=method, url=url, json=data, params=params,
headers=self._get_headers(), timeout=self.timeout)
# Success
if response.status_code in (200, 201):
return response.json()
# Auth error — NEVER retry
if response.status_code == 401:
frappe.log_error(f"Auth failed: {response.text[:500]}", "API Auth")
frappe.throw(_("Authentication failed"), exc=frappe.AuthenticationError)
# Permission error — NEVER retry
if response.status_code == 403:
frappe.throw(_("API access denied"), exc=frappe.PermissionError)
# Not found — NEVER retry
if response.status_code == 404:
frappe.throw(_("Resource not found: {0}").format(endpoint),
exc=frappe.DoesNotExistError)
# Rate limit — wait and retry
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 60))
time.sleep(min(wait, 120))
continue
# Other client errors — NEVER retry
if 400 <= response.status_code < 500:
error_msg = self._parse_error(response)
frappe.throw(_("API error: {0}").format(error_msg),
exc=frappe.ValidationError)
# Server errors — retry with backoff
if response.status_code >= 500:
last_error = f"Server error {response.status_code}"
time.sleep(2 ** attempt)
continue
except requests.exceptions.Timeout:
last_error = "Timeout"
time.sleep(2 ** attempt)
continue
except requests.exceptions.ConnectionError:
last_error = "Connection failed"
time.sleep(2 ** attempt)
continue
except (frappe.ValidationError, frappe.AuthenticationError,
frappe.PermissionError, frappe.DoesNotExistError):
raise # NEVER retry Frappe exceptions
except Exception as e:
last_error = str(e)
frappe.log_error(frappe.get_traceback(), "API Client Error")
break
frappe.log_error(f"Failed after {self.max_retries} attempts: {last_error}",
"API Client Failure")
frappe.throw(_("External service unavailable. Please try again later."))
def _parse_error(self, response):
try:
data = response.json()
return (data.get("error", {}).get("message")
or data.get("message") or data.get("detail") or str(data))
except Exception:
return response.text[:200]
def get(self, endpoint, params=None):
return self.request("GET", endpoint, params=params)
def post(self, endpoint, data):
return self.request("POST", endpoint, data=data)
def put(self, endpoint, data):
return self.request("PUT", endpoint, data=data)
def delete(self, endpoint):
return self.request("DELETE", endpoint)---
Pattern 4: Webhook Handler with Signature Verification
# myapp/webhooks.py
import frappe
import json
import hmac
import hashlib
@frappe.whitelist(allow_guest=True)
def incoming_webhook():
"""
Webhook receiver with full error handling.
ALWAYS: verify signature, parse JSON safely, return 200 quickly.
NEVER: process synchronously, trust unverified payloads.
"""
payload = frappe.request.data
signature = frappe.request.headers.get("X-Webhook-Signature")
# 1. Verify signature
if not _verify_signature(payload, signature):
frappe.local.response["http_status_code"] = 401
return {"error": "Invalid signature"}
# 2. Parse JSON safely
try:
data = json.loads(payload)
except json.JSONDecodeError:
frappe.local.response["http_status_code"] = 400
return {"error": "Invalid JSON"}
# 3. Check for duplicate (idempotency)
event_id = data.get("id")
if event_id and frappe.db.exists("Webhook Event Log", {"event_id": event_id}):
return {"status": "already_processed"}
# 4. Enqueue processing — return 200 immediately
frappe.enqueue("myapp.webhooks.process_event",
queue="short", data=data, event_id=event_id)
return {"status": "accepted"}
def _verify_signature(payload, signature):
if not signature:
return False
try:
secret = frappe.get_single("Webhook Settings").get_password("secret")
computed = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(computed, signature)
except Exception:
frappe.log_error(frappe.get_traceback(), "Webhook signature verification")
return False
def process_event(data, event_id=None):
"""Process webhook event in background."""
try:
event_type = data.get("type")
handlers = {
"payment.completed": handle_payment,
"order.created": handle_order,
}
handler = handlers.get(event_type)
if handler:
handler(data.get("data", {}))
# Mark as processed
if event_id:
frappe.get_doc({
"doctype": "Webhook Event Log",
"event_id": event_id,
"event_type": event_type,
"processed_at": frappe.utils.now()
}).insert(ignore_permissions=True)
frappe.db.commit()
except Exception:
frappe.log_error(frappe.get_traceback(), f"Webhook processing: {event_id}")---
Pattern 5: Client-Side Error Handler
// myapp/public/js/api_handler.js
class APIHandler {
static async call(options) {
return new Promise((resolve, reject) => {
frappe.call({
freeze: true,
freeze_message: __("Processing..."),
...options,
callback: (r) => {
if (r.message && r.message.status === "error") {
this.handleError(r.message);
reject(r.message);
} else {
resolve(r.message);
}
},
error: (r) => {
this.handleError(r);
reject(r);
}
});
});
}
static handleError(error) {
const info = this.parseError(error);
frappe.msgprint({title: info.title, message: info.message, indicator: info.indicator});
}
static parseError(error) {
let title = __("Error"), message = __("An error occurred"), indicator = "red";
// Extract server messages
if (error._server_messages) {
try {
const msgs = JSON.parse(error._server_messages);
if (msgs.length > 0) {
const msg = JSON.parse(msgs[0]);
message = msg.message || msg;
}
} catch (e) {}
}
// Map error types to titles
switch (error.exc_type || error.type) {
case "ValidationError": title = __("Validation Error"); break;
case "PermissionError": title = __("Permission Denied"); break;
case "DoesNotExistError": title = __("Not Found"); break;
case "DuplicateEntryError": title = __("Duplicate"); indicator = "orange"; break;
case "RateLimitExceededError": title = __("Rate Limited"); indicator = "orange"; break;
}
// Network error
if (!error.status && !error.type && !error.exc_type) {
title = __("Network Error");
message = __("Unable to connect. Check your connection.");
indicator = "orange";
}
return {title, message, indicator};
}
}---
Quick Reference: Error Throwing
# Validation (HTTP 417)
frappe.throw(_("Invalid input"), exc=frappe.ValidationError)
# Not found (HTTP 404)
frappe.throw(_("Not found"), exc=frappe.DoesNotExistError)
# Permission denied (HTTP 403)
frappe.throw(_("Access denied"), exc=frappe.PermissionError)
# Duplicate (HTTP 409)
frappe.throw(_("Duplicate"), exc=frappe.DuplicateEntryError)
# Log + throw (generic 417)
frappe.log_error(frappe.get_traceback(), "Context")
frappe.throw(_("Something went wrong"))