$npx -y skills add Impertio-Studio/Frappe_Claude_Skill_Package --skill frappe-errors-apiUse when debugging or handling API errors in Frappe/ERPNext v14/v15/v16. Prevents silent failures and wrong HTTP status codes in REST endpoints. Covers 401 Unauthorized (wrong token format, expired OAuth), 403 Forbidden (missing @whitelist, allow_guest needed), 404 Not Found (wro
| 1 | # API Error Handling |
| 2 | |
| 3 | For API implementation patterns see `frappe-core-api`. For permission errors see `frappe-errors-permissions`. |
| 4 | |
| 5 | --- |
| 6 | |
| 7 | ## HTTP Status Code Map: Error -> Cause -> Fix |
| 8 | |
| 9 | | Code | Frappe Exception | When It Happens | Fix | |
| 10 | |------|-----------------|-----------------|-----| |
| 11 | | 200 | — | Success | — | |
| 12 | | 401 | `AuthenticationError` | Bad/expired token, wrong format | Check `Authorization: token key:secret` or `Bearer access_token` | |
| 13 | | 403 | `PermissionError` | Missing `@whitelist`, no role, no `allow_guest` | Add decorator or grant permission | |
| 14 | | 404 | `DoesNotExistError` | Wrong URL, doc not found, typo in endpoint path | Verify `/api/resource/:doctype/:name` or `/api/method/dotted.path` | |
| 15 | | 409 | `DuplicateEntryError` | Unique constraint violated | Check existing records before insert | |
| 16 | | 417 | `ValidationError` | `frappe.throw()` called | Fix validation logic or input data | |
| 17 | | 429 | `RateLimitExceededError` | Too many requests | Respect `Retry-After` header; throttle requests | |
| 18 | | 500 | `Exception` (unhandled) | Unhandled server error | Check Error Log; wrap in try/except | |
| 19 | | 503 | — | Server overloaded / maintenance | Retry with exponential backoff | |
| 20 | |
| 21 | --- |
| 22 | |
| 23 | ## Authentication Errors (401) |
| 24 | |
| 25 | ### Wrong Token Format |
| 26 | |
| 27 | ``` |
| 28 | Error: HTTP 401 Unauthorized |
| 29 | Cause: Using "Bearer api_key:api_secret" instead of "token api_key:api_secret" |
| 30 | ``` |
| 31 | |
| 32 | **Frappe uses TWO authentication formats — NEVER mix them:** |
| 33 | |
| 34 | | Method | Header Format | When to Use | |
| 35 | |--------|--------------|-------------| |
| 36 | | API Key/Secret | `Authorization: token api_key:api_secret` | Server-to-server, scripts | |
| 37 | | OAuth Bearer | `Authorization: Bearer access_token` | OAuth 2.0 flows | |
| 38 | | Session Cookie | Cookie from `/api/method/login` | Browser-based apps | |
| 39 | |
| 40 | ```python |
| 41 | # WRONG — Bearer with API key:secret |
| 42 | headers = {"Authorization": f"Bearer {api_key}:{api_secret}"} |
| 43 | |
| 44 | # CORRECT — token keyword for API key:secret |
| 45 | headers = {"Authorization": f"token {api_key}:{api_secret}"} |
| 46 | |
| 47 | # CORRECT — Bearer for OAuth access tokens only |
| 48 | headers = {"Authorization": f"Bearer {oauth_access_token}"} |
| 49 | ``` |
| 50 | |
| 51 | ### Expired OAuth Token |
| 52 | |
| 53 | ``` |
| 54 | Error: HTTP 401 after token was working |
| 55 | Cause: OAuth access_token expired |
| 56 | Fix: Use refresh_token to get new access_token |
| 57 | ``` |
| 58 | |
| 59 | ```python |
| 60 | def get_fresh_token(settings): |
| 61 | """ALWAYS implement token refresh for OAuth integrations.""" |
| 62 | if is_token_expired(settings.token_expiry): |
| 63 | response = requests.post(f"{settings.base_url}/api/method/frappe.integrations.oauth2.get_token", data={ |
| 64 | "grant_type": "refresh_token", |
| 65 | "refresh_token": settings.get_password("refresh_token"), |
| 66 | "client_id": settings.client_id, |
| 67 | }) |
| 68 | if response.status_code == 200: |
| 69 | data = response.json() |
| 70 | settings.access_token = data["access_token"] |
| 71 | settings.token_expiry = frappe.utils.add_to_date(None, seconds=data["expires_in"]) |
| 72 | settings.save(ignore_permissions=True) |
| 73 | else: |
| 74 | frappe.throw(_("OAuth token refresh failed"), exc=frappe.AuthenticationError) |
| 75 | return settings.access_token |
| 76 | ``` |
| 77 | |
| 78 | --- |
| 79 | |
| 80 | ## Forbidden Errors (403) |
| 81 | |
| 82 | ### Missing @frappe.whitelist() |
| 83 | |
| 84 | ``` |
| 85 | Error: HTTP 403 on /api/method/myapp.api.my_function |
| 86 | Cause: Function exists but lacks @frappe.whitelist() decorator |
| 87 | Fix: Add decorator — without it, NO external call is allowed |
| 88 | ``` |
| 89 | |
| 90 | ```python |
| 91 | # WRONG — Callable internally but returns 403 via REST |
| 92 | def my_function(name): |
| 93 | return frappe.get_doc("Item", name) |
| 94 | |
| 95 | # CORRECT — Exposed to authenticated users |
| 96 | @frappe.whitelist() |
| 97 | def my_function(name): |
| 98 | return frappe.get_doc("Item", name) |
| 99 | |
| 100 | # CORRECT — Exposed to everyone including unauthenticated |
| 101 | @frappe.whitelist(allow_guest=True) |
| 102 | def public_function(): |
| 103 | return {"status": "ok"} |
| 104 | ``` |
| 105 | |
| 106 | ### Missing allow_guest for Public Endpoints |
| 107 | |
| 108 | ``` |
| 109 | Error: HTTP 403 for unauthenticated requests |
| 110 | Cause: @frappe.whitelist() without allow_guest=True |
| 111 | Fix: Add allow_guest=True — but ALWAYS validate inputs |
| 112 | ``` |
| 113 | |
| 114 | **NEVER use `all |