ERP Error Handling & Retry Pattern Comparison: Salesforce vs SAP vs Oracle vs NetSuite vs Dynamics 365 vs Workday vs IFS vs Acumatica vs Business Central (2026)
How do error handling, rate limit headers, and retry-after patterns differ across ERPs?
TL;DR
- Bottom line: Error handling varies wildly across ERPs -- Salesforce and Dynamics 365 provide the most standards-compliant HTTP 429 + Retry-After patterns, while SAP, Workday, and IFS have limited or non-standard implementations. NetSuite uses different HTTP codes for REST (429) vs SOAP (403) concurrency violations.
- Key limit: Only Salesforce and Dynamics 365 (F&O and Business Central) reliably return a
Retry-Afterheader with 429 responses. All other ERPs require you to implement your own backoff timing. - Watch out for: Partial success handling differs dramatically -- Salesforce Bulk API 2.0 provides per-record success/failure results, while Acumatica requires a second API call to check processing status, and SAP OData $batch returns mixed status per changeset.
- Best for: Designing cross-ERP retry middleware, choosing error handling strategies, and implementing resilient integration patterns across heterogeneous ERP landscapes.
- Authentication: Error responses for auth failures (401/403) are consistent across all platforms; the divergence is in rate limiting (429) and business logic error formats.
System Profile
This card compares error handling, rate limit headers, retry-after patterns, partial success behavior, and error response formats across nine major ERP platforms as of early 2026. It focuses on cloud deployments and standard API surfaces (REST, OData, SOAP).
| System | Role | API Surface | Error Response Format |
|---|---|---|---|
| Salesforce | CRM + Platform | REST, SOAP, Bulk API 2.0, Composite | JSON array of error objects |
| SAP S/4HANA Cloud | ERP | OData v2/v4, SOAP | OData error JSON / XML |
| Oracle Fusion Cloud ERP | ERP | REST, SOAP, FBDI | JSON with code + message |
| Oracle NetSuite | ERP | REST, SuiteTalk SOAP, RESTlets, SuiteQL | JSON error object (REST) / SOAP Fault (SOAP) |
| Dynamics 365 F&O | ERP | OData v4, Custom Services | OData error JSON with innererror |
| Dynamics 365 Business Central | ERP | REST (OData v4) | OData error JSON |
| Workday | HCM + Financials | REST, SOAP, RaaS | JSON errors array / SOAP Fault |
| IFS Cloud | ERP | OData, REST | OData-style JSON with code + message + details |
| Acumatica | ERP | REST, OData, Screen-Based | JSON with ExceptionMessage / custom error fields |
API Surfaces & Capabilities
| API Surface | Protocol | Error Format | 429 Support | Retry-After Header | Partial Success |
|---|---|---|---|---|---|
| Salesforce REST | HTTPS/JSON | [{"errorCode":"...","message":"..."}] | Yes | Yes (seconds) | No (all-or-nothing) |
| Salesforce Composite | HTTPS/JSON | Per-subrequest status codes | Yes | Yes | Yes (per subrequest) |
| Salesforce Bulk API 2.0 | HTTPS/CSV | Per-record success/failure files | Yes | Yes | Yes (per record) |
| SAP OData v4 | HTTPS/JSON | {"error":{"code":"...","message":"..."}} | Infrastructure-dependent | Infrastructure-dependent | Yes (via $batch) |
| Oracle Fusion REST | HTTPS/JSON | RFC 7807 problem details | Yes (429) | Not guaranteed | No |
| NetSuite REST | HTTPS/JSON | {"type":"...","title":"...","status":429} | Yes (429) | Not guaranteed | No |
| NetSuite SuiteTalk SOAP | HTTPS/XML | SOAP Fault with statusCode | No (uses 403) | No | Yes (per-record in lists) |
| Dynamics 365 OData | HTTPS/JSON | OData error + innererror chain | Yes | Yes (seconds) | Yes (via $batch) |
| Business Central REST | HTTPS/JSON | {"error":{"code":"...","message":"..."}} | Yes | Yes (seconds) | No |
| Workday REST | HTTPS/JSON | {"errors":[{"error":"..."}]} | Yes (429) | Not documented | No |
| IFS OData | HTTPS/JSON | OData-style JSON with details[] | Not standard | No | No |
| Acumatica REST | HTTPS/JSON | {"ExceptionMessage":"...","ExceptionType":"..."} | No (500 on queue full) | No | Via separate status check |
Rate Limits & Quotas
Rate Limit Header Comparison
| System | Rate Limit Status Code | Retry-After Header | Rate Limit Headers | Error Body Format |
|---|---|---|---|---|
| Salesforce | 429 Too Many Requests | Yes -- value in seconds | None standard; use /limits endpoint | [{"errorCode":"REQUEST_LIMIT_EXCEEDED","message":"..."}] |
| SAP S/4HANA | Depends on API Mgmt | Depends on API Mgmt config | Only if SAP API Management deployed | OData error JSON |
| Oracle Fusion | 429 Too Many Requests | Not reliably returned | None standard | {"code":"TooManyRequests","message":"User-rate limit exceeded."} |
| NetSuite REST | 429 Too Many Requests | Not reliably returned | None standard | {"status":429,"o:errorDetails":[{"o:errorCode":"CONCUR_LIMIT_EXCEEDED"}]} |
| NetSuite SOAP | 403 Forbidden | No | No | SOAP Fault: SSS_REQUEST_LIMIT_EXCEEDED |
| Dynamics 365 F&O | 429 Too Many Requests | Yes -- value in seconds | None standard | {"error":{"code":"429","message":"Number of requests exceeded the limit..."}} |
| Business Central | 429 Too Many Requests | Yes -- value in seconds | None standard | {"error":{"code":"too_many_requests","message":"..."}} |
| Workday | 429 Too Many Requests | Not publicly documented | Not documented | Varies by API surface |
| IFS Cloud | 429 (not standard) | No | No | OData-style {"error":{"code":"...","message":"..."}} |
| Acumatica | 500 (queue full) | No | No | {"ExceptionMessage":"The request queue is full..."} |
Error Response Format Deep Dive
Salesforce REST API
[
{
"errorCode": "REQUEST_LIMIT_EXCEEDED",
"message": "TotalRequests Limit exceeded.",
"fields": []
}
]
SAP S/4HANA OData v4
{
"error": {
"code": "/IWBEP/CM_V4_RUNTIME/021",
"message": "Resource not found for segment 'InvalidEntity'",
"@SAP.severity": "error",
"target": "InvalidEntity",
"details": []
}
}
Oracle Fusion REST
{
"type": "http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html",
"title": "Too Many Requests",
"status": 429,
"detail": "User-rate limit exceeded.",
"o:errorCode": "TooManyRequests"
}
NetSuite REST
{
"type": "https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.30",
"title": "Too Many Requests",
"status": 429,
"o:errorDetails": [
{
"detail": "Concurrency limit for this account has been exceeded.",
"o:errorCode": "CONCUR_LIMIT_EXCEEDED"
}
]
}
Dynamics 365 F&O
{
"error": {
"code": "429",
"message": "Number of requests exceeded the limit of 6000 over the time window of 300 seconds.",
"innererror": {
"message": "Rate limit exceeded",
"type": "Microsoft.Dynamics.Platform.Integration.Services.OData.AxRateLimitException"
}
}
}
IFS Cloud
{
"error": {
"code": "DATABASE_ERROR",
"message": "Operation failed due to a database constraint violation.",
"details": [
{
"code": "ORA-20124",
"message": "Error.NULLVALUE: Field [NAME] is mandatory and requires a value."
}
]
}
}
Acumatica
{
"ExceptionMessage": "Error: The following validation errors occurred: 'Customer ID' cannot be empty.",
"ExceptionType": "PX.Api.ContractBased.OutcomeEntityHasErrorsException",
"StackTrace": "..."
}
Authentication
| System | Auth Error Code | Error Format | Common Auth Errors |
|---|---|---|---|
| Salesforce | 401 Unauthorized | [{"errorCode":"INVALID_SESSION_ID","message":"..."}] | Token expired, invalid connected app, IP restrictions |
| SAP S/4HANA | 401 / 403 | OData error JSON | Certificate mismatch, scope insufficient, XSRF token missing |
| Oracle Fusion | 401 Unauthorized | JSON problem details | Basic auth credentials wrong, OAuth token expired |
| NetSuite | 401 Unauthorized | JSON error object | TBA token revoked, OAuth token expired, invalid consumer key |
| Dynamics 365 | 401 / 403 | OData error JSON | Entra ID token expired, insufficient app permissions |
| Business Central | 401 Unauthorized | OData error JSON | OAuth token expired, wrong tenant |
| Workday | 401 Unauthorized | JSON or SOAP Fault | ISU credentials wrong, OAuth refresh token expired |
| IFS Cloud | 401 / 403 | JSON error | Invalid API key, insufficient permissions |
| Acumatica | 401 Unauthorized | JSON | Login limit exceeded, session expired |
Authentication Gotchas
- Salesforce: A 401 on REST API can mean either an expired session OR an invalid instance URL (after a sandbox refresh, instance URLs change). Always re-discover the instance URL via login.salesforce.com. [src1]
- NetSuite: TBA token-based auth returns 401 with
INVALID_LOGIN_ATTEMPTeven when the token is valid but the integration record is disabled. Check integration record status first. [src4] - Dynamics 365 F&O: A 403 after successful authentication usually means the user lacks the required security role, not that auth failed. The error message does not always distinguish between auth and authorization failures. [src2]
Constraints
- Salesforce dual error systems: Governor limit errors are synchronous transaction aborts with NO Retry-After header. Rate limit errors (daily quota) return 429 with Retry-After. These require completely different handling.
- NetSuite dual HTTP codes: REST returns 429 for concurrency/rate violations; SOAP returns 403. Both mean the same thing but require different HTTP status handling.
- SAP has no standard throttling format: SAP S/4HANA Cloud itself does not throttle. Rate limiting only happens if SAP API Management or a third-party gateway is deployed.
- Acumatica queues before rejecting: Acumatica does not return 429. Instead, it queues up to 20 excess requests and returns HTTP 500 when the queue is full. Standard 429-based retry logic will not work.
- Workday error opacity: Workday REST API error formats are not comprehensively documented. Error shapes vary by endpoint and API version.
- IFS Oracle errors leak through: IFS database errors surface raw Oracle error codes (ORA-xxxxx) in API responses.
- Business Central per-user throttling: Limits are enforced per-user, per-environment. A single service account will hit limits faster than distributed service principals.
Integration Pattern Decision Tree
START -- How should I handle errors across ERP integrations?
|
+-- What type of error are you handling?
| |
| +-- Rate limiting (HTTP 429)
| | +-- Does the ERP return Retry-After header?
| | | +-- YES (Salesforce, D365 F&O, Business Central)
| | | | --> Use Retry-After value directly
| | | +-- NO (Oracle, NetSuite, Workday, IFS)
| | | --> Exponential backoff: 2^n seconds, max 60s, with jitter
| | +-- Does the ERP use 429?
| | +-- YES (all except SAP, Acumatica, NetSuite SOAP) --> Standard handler
| | +-- NO -- SAP: gateway-dependent
| | +-- NO -- Acumatica: returns 500 with queue-full message
| | +-- NO -- NetSuite SOAP: returns 403
| |
| +-- Business logic errors (400/422)
| | +-- Retryable? --> Retry with backoff (3 attempts max)
| | +-- Non-retryable --> Log, skip, dead letter queue
| |
| +-- Partial success (bulk/batch operations)
| | +-- Salesforce Bulk API 2.0 --> Download successfulResults + failedResults
| | +-- Salesforce Composite --> Check each subrequest httpStatusCode
| | +-- SAP $batch --> Check each changeset status
| | +-- NetSuite SOAP --> Check writeResponseList per record
| | +-- Dynamics 365 $batch --> Check each response in multipart body
| | +-- Acumatica --> Call process status endpoint separately
| |
| +-- Server errors (500/503) --> Always retryable with exponential backoff
|
+-- Error tolerance?
+-- Zero-loss --> Idempotency keys + dead letter queue + reconciliation
+-- Best-effort --> Exponential backoff with max retries, then log and alert
Quick Reference
| Capability | Salesforce | SAP S/4HANA | Oracle Fusion | NetSuite | D365 F&O | D365 BC | Workday | IFS Cloud | Acumatica |
|---|---|---|---|---|---|---|---|---|---|
| Rate limit HTTP code | 429 | Gateway-dependent | 429 | 429 (REST) / 403 (SOAP) | 429 | 429 | 429 | Non-standard | 500 (queue full) |
| Retry-After header | Yes | Gateway-dependent | Not guaranteed | Not guaranteed | Yes | Yes | Not documented | No | No |
| Error format | JSON array | OData JSON | RFC 7807 JSON | JSON + o:errorDetails | OData + innererror | OData JSON | JSON errors array | OData + details | Exception JSON |
| Partial success | Yes (Bulk, Composite) | Yes ($batch) | No | Yes (SOAP lists) | Yes ($batch) | No | No | No | Separate status call |
| Error code field | errorCode | error.code | o:errorCode | o:errorCode | error.code | error.code | error | error.code | ExceptionType |
| Field-level errors | Yes (fields[]) | Partial (target) | No | Partial | Yes (innererror) | No | Yes (field) | Yes (details[]) | No |
| Governor/transaction errors | Yes (extensive) | No | No | Yes (governance units) | Execution time pool | No | No | No | No |
| Idempotency support | Sforce-Call-Options | If-Match ETag | None standard | None standard | If-Match ETag | If-Match ETag | None standard | If-Match ETag | None standard |
| Recommended backoff | Retry-After value | 2^n seconds | 2^n, max 60s | 2^n + jitter | Retry-After value | Retry-After value | 2^n + jitter | 2^n, max 60s | 60s fixed then 2^n |
Step-by-Step Integration Guide
1. Detect the error type from HTTP status code
Every ERP returns standard HTTP status codes, but the meaning and recommended handling differs. [src1, src2]
function classifyError(httpStatus, body, erpSystem) {
if (httpStatus === 429) return 'RATE_LIMITED';
if (httpStatus === 403 && erpSystem === 'netsuite_soap') return 'RATE_LIMITED';
if (httpStatus === 500 && erpSystem === 'acumatica'
&& body?.ExceptionMessage?.includes('queue is full')) return 'RATE_LIMITED';
if (httpStatus === 401) return 'AUTH_EXPIRED';
if (httpStatus === 403) return 'FORBIDDEN';
if (httpStatus >= 400 && httpStatus < 500) return 'CLIENT_ERROR';
if (httpStatus >= 500) return 'SERVER_ERROR';
return 'SUCCESS';
}
Verify: Send a request with an expired token to each ERP -> all should return AUTH_EXPIRED.
2. Extract retry timing from response headers
Parse the Retry-After header when available; fall back to exponential backoff. [src1, src2, src5]
function getRetryDelay(response, attempt, erpSystem) {
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) return seconds * 1000;
const date = new Date(retryAfter);
if (!isNaN(date)) return Math.max(0, date - Date.now());
}
const baseDelays = {
salesforce: 2000, sap: 3000, oracle_fusion: 5000,
netsuite: 2000, dynamics365: 2000, business_central: 2000,
workday: 5000, ifs: 3000, acumatica: 60000,
};
const base = baseDelays[erpSystem] || 2000;
const delay = base * Math.pow(2, attempt);
const jitter = delay * 0.2 * Math.random();
return Math.min(delay + jitter, 120000);
}
Verify: Simulate a 429 with Retry-After: 30 header -> function should return 30000.
3. Implement the retry loop with ERP-aware logic
Combine classification, delay calculation, and max-retry policies. [src1, src2, src3]
async function fetchWithErpRetry(url, options, erpSystem, maxRetries = 5) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
const body = await response.json().catch(() => null);
const errorType = classifyError(response.status, body, erpSystem);
if (errorType === 'SUCCESS') return { response, body };
if (errorType === 'AUTH_EXPIRED') {
options.headers.Authorization = await refreshToken(erpSystem);
continue;
}
if (errorType === 'RATE_LIMITED' || errorType === 'SERVER_ERROR') {
if (attempt === maxRetries) throw new Error(`${errorType} after ${maxRetries} retries`);
const delay = getRetryDelay(response, attempt, erpSystem);
await new Promise(r => setTimeout(r, delay));
continue;
}
throw new Error(`Non-retryable error from ${erpSystem}: ${JSON.stringify(body)}`);
}
}
Verify: Mock a 429 for first 2 calls, 200 on third -> function should succeed after 2 retries.
4. Handle partial success in bulk operations
Each ERP has a different model for reporting per-record outcomes in batch operations. [src7, src4]
async function handleBulkResult(erpSystem, jobId, response) {
switch (erpSystem) {
case 'salesforce_bulk':
// Separate endpoints for success/failure
const failed = await fetch(`/services/data/v62.0/jobs/ingest/${jobId}/failedResults`);
const succeeded = await fetch(`/services/data/v62.0/jobs/ingest/${jobId}/successfulResults`);
return { failed: await failed.text(), succeeded: await succeeded.text() };
case 'salesforce_composite':
return {
failed: response.compositeResponse.filter(r => r.httpStatusCode >= 400),
succeeded: response.compositeResponse.filter(r => r.httpStatusCode < 400)
};
case 'netsuite_soap':
return {
failed: response.writeResponseList.writeResponse.filter(r => !r.status.isSuccess),
succeeded: response.writeResponseList.writeResponse.filter(r => r.status.isSuccess)
};
case 'acumatica':
return await fetch(`/entity/Default/24.200.001/${jobId}/status`).then(r => r.json());
default:
return { allOrNothing: true, response };
}
}
Verify: Submit a Salesforce Bulk API job with intentional invalid records -> failedResults endpoint should return specific records with error details.
Code Examples
Python: Universal ERP retry handler
# Input: ERP system name, request URL, headers, optional body
# Output: Successful response or raises after max retries
import time, random, requests # requests==2.31.0
ERP_CONFIGS = {
"salesforce": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 2},
"sap": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 3},
"oracle_fusion": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 5},
"netsuite_rest": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 2},
"netsuite_soap": {"retry_codes": [403, 503], "max_retries": 5, "base_delay": 2},
"dynamics365": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 2},
"business_central": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 2},
"workday": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 5},
"ifs": {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 3},
"acumatica": {"retry_codes": [500, 503], "max_retries": 3, "base_delay": 60},
}
def erp_request(erp_system, method, url, headers=None, json=None, **kwargs):
config = ERP_CONFIGS.get(erp_system, {"retry_codes": [429, 503], "max_retries": 5, "base_delay": 2})
for attempt in range(config["max_retries"] + 1):
resp = requests.request(method, url, headers=headers, json=json, **kwargs)
if resp.status_code < 400:
return resp
is_retryable = resp.status_code in config["retry_codes"]
if erp_system == "acumatica" and resp.status_code == 500:
body = resp.json() if resp.text else {}
if "queue is full" not in str(body.get("ExceptionMessage", "")):
is_retryable = False
if not is_retryable or attempt == config["max_retries"]:
resp.raise_for_status()
retry_after = resp.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
delay = int(retry_after)
else:
delay = config["base_delay"] * (2 ** attempt)
delay += random.uniform(0, delay * 0.2)
delay = min(delay, 120)
time.sleep(delay)
raise requests.exceptions.RetryError(f"Max retries exceeded for {erp_system}")
JavaScript/Node.js: Parse ERP-specific error responses
// Input: HTTP response object, ERP system identifier
// Output: Normalized error object { code, message, retryable, fields, raw }
function parseErpError(response, body, erpSystem) {
const normalized = { httpStatus: response.status, code: 'UNKNOWN_ERROR',
message: 'An unknown error occurred', retryable: false, fields: [], raw: body };
switch (erpSystem) {
case 'salesforce':
if (Array.isArray(body)) {
normalized.code = body[0]?.errorCode || 'UNKNOWN';
normalized.message = body[0]?.message || '';
normalized.fields = body[0]?.fields || [];
normalized.retryable = ['REQUEST_LIMIT_EXCEEDED','SERVER_UNAVAILABLE'].includes(normalized.code);
}
break;
case 'netsuite':
const details = body?.['o:errorDetails']?.[0];
normalized.code = details?.['o:errorCode'] || 'UNKNOWN';
normalized.message = details?.detail || body?.title || '';
normalized.retryable = [429, 403].includes(response.status);
break;
case 'dynamics365':
case 'business_central':
normalized.code = body?.error?.code || 'UNKNOWN';
normalized.message = body?.error?.message || '';
normalized.retryable = response.status === 429 || response.status >= 500;
break;
case 'acumatica':
normalized.code = body?.ExceptionType || 'UNKNOWN';
normalized.message = body?.ExceptionMessage || '';
normalized.retryable = normalized.message.includes('queue is full');
break;
// ... other ERPs follow similar patterns
}
return normalized;
}
cURL: Test error responses from each ERP
# === SALESFORCE: Check remaining API limits ===
curl -s -H "Authorization: Bearer $SF_TOKEN" \
"https://yourInstance.salesforce.com/services/data/v62.0/limits" \
| jq '{DailyApiRequests: {Max, Remaining}}'
# === DYNAMICS 365 F&O: Check throttle status ===
curl -s -D - -o /dev/null -H "Authorization: Bearer $D365_TOKEN" \
"https://your-instance.operations.dynamics.com/data/Customers?\$top=1"
# Look for: Retry-After header in 429 responses
# === NETSUITE: Test concurrency limit response ===
curl -s -w "\nHTTP Status: %{http_code}\n" -H "Authorization: OAuth ..." \
"https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/record/v1/customer/1"
# On 429: {"status":429,"o:errorDetails":[{"o:errorCode":"CONCUR_LIMIT_EXCEEDED"}]}
Error Handling & Failure Points
Common Error Codes
| System | Code | HTTP Status | Meaning | Retryable | Resolution |
|---|---|---|---|---|---|
| Salesforce | REQUEST_LIMIT_EXCEEDED | 429 | Daily API quota exceeded | Yes | Wait for Retry-After; buy API call packs |
| Salesforce | UNABLE_TO_LOCK_ROW | 400 | Record lock contention | Yes | Retry with jitter; avoid parallel updates to same record |
| Salesforce | GOVERNOR_LIMIT | 500 | Transaction limit exceeded | No | Bulkify code; reduce SOQL/DML per transaction |
| SAP | /IWBEP/CM_V4_RUNTIME/021 | 404 | Entity not found | No | Verify entity set name and API version |
| Oracle Fusion | TooManyRequests | 429 | Rate limit exceeded | Yes | Exponential backoff; switch to FBDI for bulk |
| NetSuite | CONCUR_LIMIT_EXCEEDED | 429 | Concurrency slots exhausted | Yes | Reduce parallel calls; reserve dedicated slots |
| NetSuite | SSS_REQUEST_LIMIT_EXCEEDED | 429/403 | Per-user rate limit exceeded | Yes | Throttle request rate; check governance dashboard |
| D365 F&O | 429 | 429 | Service protection limit | Yes | Use Retry-After header value |
| D365 BC | too_many_requests | 429 | Per-user rate limit | Yes | Use Retry-After header value |
| IFS | DATABASE_ERROR | 500 | Database constraint violation | No | Fix data; check mandatory fields |
| Acumatica | PX.Data.PXException | 500 | Queue full or business logic | Maybe | If queue-full: wait 60s and retry; otherwise: fix data |
Failure Points in Production
- Salesforce: Composite API silent subrequest failures: When
allOrNoneis false (default), Composite API returns HTTP 200 even when individual subrequests fail. Fix:Always iterate compositeResponse and handle per-subrequest errors, or set allOrNone: true.[src1, src7] - NetSuite: REST vs SOAP status code mismatch: Middleware that only handles 429 will miss SOAP concurrency violations (HTTP 403). Fix:
For NetSuite SOAP, treat 403 with SSS_REQUEST_LIMIT_EXCEEDED as equivalent to 429.[src3, src4] - Dynamics 365: Execution time limit hits before request count: The 1,200-second combined execution time in 5 minutes is often exhausted before 6,000 requests. Fix:
Simplify queries; use $select to reduce fields; monitor execution time in LCS.[src2] - Acumatica: Queue timeout masquerades as server error: HTTP 500 for queue full, not 429. Standard retry-on-429 logic never triggers. Fix:
Parse ExceptionMessage for "queue is full" and treat as rate limit. Wait 60s.[src8] - SAP: Missing XSRF token causes 403 loop: SAP OData requires X-CSRF-Token for writes. Missing token returns 403, which middleware may treat as "not authorized." Fix:
On 403 from SAP, first fetch new X-CSRF-Token with HEAD request before assuming auth failure. - IFS: Oracle error codes in API responses: IFS surfaces raw Oracle database errors (ORA-20124) in the details array. Fix:
Always parse the details array for root cause.[src6]
Anti-Patterns
Wrong: Using a single 429 handler for all ERPs
// BAD -- assumes all ERPs use HTTP 429 for rate limiting
async function callErp(url, options) {
const resp = await fetch(url, options);
if (resp.status === 429) {
await sleep(5000); // Fixed 5-second wait
return callErp(url, options);
}
return resp;
}
// Misses: NetSuite SOAP (403), Acumatica (500), SAP (gateway-dependent)
// Also: no max retries = infinite loop risk
Correct: ERP-aware error classification with proper backoff
// GOOD -- handles each ERP's unique error signaling
async function callErp(url, options, erp, attempt = 0) {
const MAX_RETRIES = 5;
const resp = await fetch(url, options);
const body = await resp.json().catch(() => null);
const isRateLimited = resp.status === 429 ||
(erp === 'netsuite_soap' && resp.status === 403) ||
(erp === 'acumatica' && resp.status === 500
&& body?.ExceptionMessage?.includes('queue'));
if (isRateLimited && attempt < MAX_RETRIES) {
const delay = getRetryDelay(resp, attempt, erp);
await sleep(delay);
return callErp(url, options, erp, attempt + 1);
}
return { resp, body };
}
Wrong: Ignoring partial success in bulk operations
// BAD -- assumes bulk operation is all-or-nothing
const result = await sfBulkApi.createJob(records);
if (result.state === 'JobComplete') {
console.log('All records imported successfully!');
// WRONG: some records may have failed within a "completed" job
}
Correct: Always check per-record results in bulk operations
// GOOD -- check both success and failure results
const result = await sfBulkApi.createJob(records);
if (result.state === 'JobComplete') {
const failures = await sfBulkApi.getFailedResults(result.id);
const successes = await sfBulkApi.getSuccessfulResults(result.id);
console.log(`Imported: ${successes.length}, Failed: ${failures.length}`);
if (failures.length > 0) {
await deadLetterQueue.push(failures.map(f => ({
record: f, error: f.sf__Error, timestamp: new Date().toISOString()
})));
}
}
Wrong: Treating SAP 403 as authorization failure
// BAD -- SAP 403 often means missing XSRF token, not auth failure
if (resp.status === 403) {
throw new Error('User not authorized');
// Could be a missing X-CSRF-Token!
}
Correct: Check for XSRF token requirement on SAP 403
// GOOD -- differentiate between auth failure and missing XSRF token
if (resp.status === 403) {
const body = await resp.json().catch(() => null);
if (body?.error?.code?.includes('CSRF') || !options.headers['X-CSRF-Token']) {
const tokenResp = await fetch(url, {
method: 'HEAD',
headers: { ...options.headers, 'X-CSRF-Token': 'Fetch' }
});
options.headers['X-CSRF-Token'] = tokenResp.headers.get('X-CSRF-Token');
return callErp(url, options, 'sap'); // Retry with new token
}
throw new Error('Authorization failure: check user roles and permissions');
}
Common Pitfalls
- Hardcoding Retry-After values: Only Salesforce, D365 F&O, and Business Central reliably return Retry-After headers. Fix:
Always parse Retry-After when present; fall back to exponential backoff with ERP-specific base delays.[src1, src2, src5] - Not handling NetSuite's dual HTTP codes: Developers who only handle 429 miss SOAP concurrency violations (403). Fix:
Check the error code string (CONCUR_LIMIT_EXCEEDED) in addition to HTTP status.[src3, src4] - Acumatica standard retry never fires: Returns 500 for queue-full, not 429. Fix:
Parse Acumatica 500 responses for "queue is full" messages and treat as retryable.[src8] - Confusing Salesforce governor limits with rate limits: Governor limits abort transactions (no retry possible). Rate limits (429) allow retry. Fix:
Never retry governor limit errors without changing the request.[src1] - SAP: Expecting rate limits without API Management: SAP S/4HANA Cloud has no built-in throttling. Fix:
Deploy SAP API Management with spike arrest policies. - IFS: Ignoring the details array: The actual root cause is often in the details array with raw Oracle error codes. Fix:
Always parse and log the full details array.[src6] - D365: Not distributing across service principals: Per-user limits mean a single integration account hits limits fast. Fix:
Create multiple service principals and distribute traffic.[src2, src5]
Diagnostic Commands
# === SALESFORCE: Check remaining daily API quota ===
curl -s -H "Authorization: Bearer $SF_TOKEN" \
"https://yourInstance.salesforce.com/services/data/v62.0/limits" \
| jq '{DailyApiRequests: {Max, Remaining}}'
# === SALESFORCE: Check Bulk API job failures ===
curl -s -H "Authorization: Bearer $SF_TOKEN" \
"https://yourInstance.salesforce.com/services/data/v62.0/jobs/ingest/$JOB_ID/failedResults"
# === NETSUITE: Check concurrency governance ===
# Navigate: Setup > Integration > Integration Management > Integration Governance
# === DYNAMICS 365 F&O: Monitor throttle events ===
# LCS > Environment Monitoring > Raw Logs > Filter: ThrottledRequests
curl -s -D - -o /dev/null -H "Authorization: Bearer $D365_TOKEN" \
"https://your-instance.operations.dynamics.com/data/Customers?\$top=1"
# === BUSINESS CENTRAL: Test rate limit handling ===
curl -s -w "\nHTTP: %{http_code}\nRetry-After: %header{Retry-After}\n" \
-H "Authorization: Bearer $BC_TOKEN" \
"https://api.businesscentral.dynamics.com/v2.0/$TENANT_ID/$ENVIRONMENT/api/v2.0/companies"
# === IFS: Check error response format ===
curl -s -H "Authorization: Bearer $IFS_TOKEN" \
"https://your-ifs-instance.ifs.cloud/main/ifsapplications/projection/v1/InvalidEndpoint" \
| jq '{code: .error.code, message: .error.message, details: .error.details}'
Cross-System Comparison
| Capability | Salesforce | SAP S/4HANA | Oracle Fusion | NetSuite | D365 F&O | D365 BC | Workday | IFS Cloud | Acumatica |
|---|---|---|---|---|---|---|---|---|---|
| Rate Limit HTTP Code | 429 | Gateway-dependent | 429 | 429 (REST) / 403 (SOAP) | 429 | 429 | 429 | Non-standard | 500 (queue full) |
| Retry-After Header | Yes (seconds) | Gateway-dependent | Not guaranteed | Not guaranteed | Yes (seconds) | Yes (seconds) | Not documented | No | No |
| Error JSON Structure | Array of objects | OData error | RFC 7807 | o:errorDetails | OData + innererror | OData error | Errors array | OData + details | Exception object |
| Partial Success (Bulk) | Yes (Bulk, Composite) | Yes ($batch) | No | Yes (SOAP lists) | Yes ($batch) | No | No | No | Separate status endpoint |
| Field-Level Error Info | Yes (fields[]) | Partial (target) | No | Partial | Yes (innererror) | No | Yes (field) | Yes (details[]) | No |
| Governor/Transaction Errors | Yes (extensive) | No | No | Yes (governance units) | Execution time pool | No | No | No | No |
| Idempotency Mechanism | Sforce-Call-Options | If-Match ETag | None standard | None standard | If-Match ETag | If-Match ETag | None standard | If-Match ETag | None standard |
| Error Documentation Quality | Excellent | Good (OData) | Moderate | Good | Good | Good | Poor | Moderate | Moderate |
| Recommended Backoff | Retry-After value | 2^n seconds | 2^n, max 60s | 2^n + jitter | Retry-After value | Retry-After value | 2^n + jitter | 2^n, max 60s | 60s fixed then 2^n |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Designing retry/error handling for multi-ERP middleware | Need rate limit quotas and thresholds | ERP Rate Limits Comparison |
| Building a universal ERP integration adapter | Need dead letter queue architecture | Error Handling & Dead Letter Queues |
| Debugging why retry logic works on one ERP but not another | Need authentication flow details | ERP Authentication Comparison |
| Comparing ERPs for integration resilience | Need single-system deep dive | System-specific API capability card |
| Implementing observability across ERP integrations | Need bulk import comparison | ERP Bulk Import Comparison |
Important Caveats
- Error response formats can change with API version updates. Salesforce changes error codes between major versions. SAP OData v2 and v4 have different error schemas. Always test error handling against your specific API version.
- SAP S/4HANA error behavior depends entirely on the infrastructure layer. Without SAP API Management, there are no 429 responses or Retry-After headers.
- Workday error documentation is the least comprehensive of all nine platforms. The error shapes shown here are based on community observation and may vary.
- Acumatica's queue-full behavior (HTTP 500 instead of 429) is by design, not a bug. Standard retry-on-429 libraries will not handle Acumatica correctly out of the box.
- NetSuite's dual HTTP code pattern (429 for REST, 403 for SOAP) is documented but frequently overlooked.
- IFS Cloud surfaces raw Oracle database error codes (ORA-xxxxx) in API responses. These are implementation details that could change.
- All error formats shown are based on current stable releases as of early 2026. Check vendor release notes for changes in your specific version.