Retry-After header with 429 responses. All other ERPs require you to implement your own backoff timing.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 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 |
| 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..."} |
[
{
"errorCode": "REQUEST_LIMIT_EXCEEDED",
"message": "TotalRequests Limit exceeded.",
"fields": []
}
]
{
"error": {
"code": "/IWBEP/CM_V4_RUNTIME/021",
"message": "Resource not found for segment 'InvalidEntity'",
"@SAP.severity": "error",
"target": "InvalidEntity",
"details": []
}
}
{
"type": "http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html",
"title": "Too Many Requests",
"status": 429,
"detail": "User-rate limit exceeded.",
"o:errorCode": "TooManyRequests"
}
{
"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"
}
]
}
{
"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"
}
}
}
{
"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."
}
]
}
}
{
"ExceptionMessage": "Error: The following validation errors occurred: 'Customer ID' cannot be empty.",
"ExceptionType": "PX.Api.ContractBased.OutcomeEntityHasErrorsException",
"StackTrace": "..."
}
| 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 |
INVALID_LOGIN_ATTEMPT even when the token is valid but the integration record is disabled. Check integration record status first. [src4]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
| 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 |
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.
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.
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.
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.
# 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}")
// 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;
}
# === 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"}]}
| 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 |
allOrNone is 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]For NetSuite SOAP, treat 403 with SSS_REQUEST_LIMIT_EXCEEDED as equivalent to 429. [src3, src4]Simplify queries; use $select to reduce fields; monitor execution time in LCS. [src2]Parse ExceptionMessage for "queue is full" and treat as rate limit. Wait 60s. [src8]On 403 from SAP, first fetch new X-CSRF-Token with HEAD request before assuming auth failure.Always parse the details array for root cause. [src6]// 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
// 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 };
}
// 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
}
// 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()
})));
}
}
// 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!
}
// 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');
}
Always parse Retry-After when present; fall back to exponential backoff with ERP-specific base delays. [src1, src2, src5]Check the error code string (CONCUR_LIMIT_EXCEEDED) in addition to HTTP status. [src3, src4]Parse Acumatica 500 responses for "queue is full" messages and treat as retryable. [src8]Never retry governor limit errors without changing the request. [src1]Deploy SAP API Management with spike arrest policies.Always parse and log the full details array. [src6]Create multiple service principals and distribute traffic. [src2, src5]# === 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}'
| 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 |
| 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 |