---
# === IDENTITY ===
id: business/erp-integration/error-handling-retry-comparison/2026
canonical_question: "How do error handling, rate limit headers, and retry-after patterns differ across ERPs?"
aliases:
  - "ERP API error response format comparison"
  - "Which ERPs support Retry-After headers?"
  - "Salesforce vs SAP vs Oracle vs NetSuite error handling comparison"
  - "ERP integration retry logic best practices cross-platform"
entity_type: erp_integration
domain: business > erp-integration > error-handling-retry-comparison
region: global
jurisdiction: global
temporal_scope: 2025-2026

# === SYSTEM PROFILE ===
systems:
  - name: "Salesforce"
    vendor: "Salesforce"
    version: "API v62.0"
    edition: "Enterprise, Unlimited, Developer"
    deployment: "cloud"
    api_surface: "REST, SOAP, Bulk API 2.0, Composite"
  - name: "SAP S/4HANA Cloud"
    vendor: "SAP"
    version: "2408"
    edition: "Public Cloud, Private Cloud"
    deployment: "cloud"
    api_surface: "OData v2/v4, SOAP, RFC"
  - name: "Oracle Fusion Cloud ERP"
    vendor: "Oracle"
    version: "Release 24D"
    edition: "Standard"
    deployment: "cloud"
    api_surface: "REST, SOAP, FBDI"
  - name: "Oracle NetSuite"
    vendor: "Oracle"
    version: "2024.2"
    edition: "Standard, Premium, Enterprise, Ultimate"
    deployment: "cloud"
    api_surface: "REST, SuiteTalk SOAP, RESTlets, SuiteQL"
  - name: "Microsoft Dynamics 365 Finance & Operations"
    vendor: "Microsoft"
    version: "10.0.40"
    edition: "Finance, Supply Chain Management"
    deployment: "cloud"
    api_surface: "OData v4, Custom Services, DMF"
  - name: "Microsoft Dynamics 365 Business Central"
    vendor: "Microsoft"
    version: "v2.0 API"
    edition: "Essentials, Premium"
    deployment: "cloud"
    api_surface: "REST (OData v4)"
  - name: "Workday"
    vendor: "Workday"
    version: "2024 R2"
    edition: "Standard"
    deployment: "cloud"
    api_surface: "REST, SOAP, RaaS"
  - name: "IFS Cloud"
    vendor: "IFS"
    version: "24R2"
    edition: "Standard"
    deployment: "cloud"
    api_surface: "OData, REST"
  - name: "Acumatica"
    vendor: "Acumatica"
    version: "2024 R2"
    edition: "All editions"
    deployment: "cloud"
    api_surface: "REST, OData, Screen-Based, SOAP"

# === VERIFICATION ===
last_verified: 2026-03-03
confidence: 0.85
version: 1.0
first_published: 2026-03-03

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "2025-10 -- Salesforce API v62.0; Dynamics 365 F&O 10.0.36 made user-based limits optional"
  next_review: 2026-08-30
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "Salesforce returns Retry-After header on 429 but NOT on governor limit errors (those are synchronous transaction aborts)"
  - "SAP S/4HANA Cloud has no standardized rate limit headers -- throttling depends entirely on infrastructure layer (API Management, Web Dispatcher)"
  - "Oracle Fusion returns 429 with TooManyRequests error code but Retry-After header is not guaranteed in all versions"
  - "NetSuite returns 429 for REST but 403 for SOAP concurrency violations -- different status codes for the same underlying problem"
  - "Workday does not publicly document error response schemas; error formats vary by API surface (REST vs SOAP vs RaaS)"
  - "IFS Cloud returns OData-style error JSON but does not implement standard rate limit headers"
  - "Acumatica queues excess requests (up to 20) before declining -- there is no Retry-After header, just HTTP 500 with queue-full message"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs rate limit values and quotas, not error formats"
    use_instead: "business/erp-integration/erp-rate-limits-comparison/2026"
  - condition: "User needs authentication flow comparison"
    use_instead: "business/erp-integration/erp-authentication-comparison/2026"
  - condition: "User needs dead letter queue architecture patterns"
    use_instead: "business/erp-integration/error-handling-dead-letter-queues/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: integration_pattern
    question: "What integration pattern do you need?"
    type: choice
    options:
      - "real-time sync (individual records, <1s latency)"
      - "batch/bulk (scheduled, high volume)"
      - "event-driven (webhook, CDC, platform events)"
      - "file-based (CSV/XML import/export)"
  - key: data_volume
    question: "What's your daily data volume?"
    type: choice
    options:
      - "< 1,000 records/day"
      - "1,000-100,000 records/day"
      - "> 100,000 records/day"
  - key: direction
    question: "What's the data flow direction?"
    type: choice
    options:
      - "inbound (writing to this ERP)"
      - "outbound (reading from this ERP)"
      - "bidirectional sync"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/business/erp-integration/error-handling-retry-comparison/2026"
suggested_citation: "Source: knowledgelib.io -- AI Knowledge Library (verified 2026-03-03)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "business/erp-integration/erp-rate-limits-comparison/2026"
      label: "ERP rate limits and throttling comparison"
    - id: "business/erp-integration/erp-rest-api-comparison/2026"
      label: "ERP REST API capabilities comparison"
    - id: "business/erp-integration/erp-authentication-comparison/2026"
      label: "ERP authentication flow comparison"
  solves:
    - id: "business/erp-integration/error-handling-dead-letter-queues/2026"
      label: "Dead letter queue patterns for ERP integrations"
  alternative_to: []
  often_confused_with:
    - id: "business/erp-integration/erp-rate-limits-comparison/2026"
      label: "Rate limits card covers quotas and thresholds; this card covers error response formats and retry logic"

# === SOURCES ===
sources:
  - id: src1
    title: "API Request Limits and Allocations -- Salesforce Developer Limits Quick Reference"
    author: Salesforce
    url: https://developer.salesforce.com/docs/atlas.en-us.salesforce_app_limits_cheatsheet.meta/salesforce_app_limits_cheatsheet/salesforce_app_limits_platform_api.htm
    type: official_docs
    published: 2025-11-01
    reliability: authoritative
  - id: src2
    title: "Service Protection API Limits -- Dynamics 365 Finance & Operations"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/service-protection-api-limits
    type: official_docs
    published: 2026-01-20
    reliability: authoritative
  - id: src3
    title: "NetSuite API Governance: Concurrency & Rate Limits Explained"
    author: Houseblend
    url: https://www.houseblend.io/articles/netsuite-api-governance-guide
    type: technical_blog
    published: 2025-06-15
    reliability: moderate_high
  - id: src4
    title: "Error Handling in REST Web Services -- NetSuite"
    author: Oracle
    url: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156570709583.html
    type: official_docs
    published: 2025-01-01
    reliability: authoritative
  - id: src5
    title: "Working with API Limits in Dynamics 365 Business Central"
    author: Microsoft
    url: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-rate-limits
    type: official_docs
    published: 2025-09-01
    reliability: authoritative
  - id: src6
    title: "Responses and Status Codes -- IFS Cloud Documentation"
    author: IFS
    url: https://docs.ifs.com/techdocs/24r2/040_tailoring/300_extensibility/020_api_explorer/050_responses_and_status_codes/
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src7
    title: "Errors -- Salesforce Bulk API 2.0 Developer Guide"
    author: Salesforce
    url: https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_reference_errors.htm
    type: official_docs
    published: 2025-11-01
    reliability: authoritative
  - id: src8
    title: "Acumatica REST API Documentation: Examples & Best Practices"
    author: Concentrus
    url: https://concentrus.com/acumatica-rest-api-documentation/
    type: technical_blog
    published: 2025-04-01
    reliability: moderate_high
---

# 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)

## 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-After` header 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). On-premise deployments may have different error behavior due to custom infrastructure configurations.

| 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 per request) |
| 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 changesets) |
| Oracle Fusion REST | HTTPS/JSON | `{"type":"...","title":"...","detail":"..."}` | 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 | `{"error":{"code":"...","message":"...","innererror":{...}}}` | Yes | Yes (seconds) | Yes (via $batch) |
| Business Central REST | HTTPS/JSON | `{"error":{"code":"...","message":"..."}}` | Yes | Yes (seconds) | No |
| Workday REST | HTTPS/JSON | `{"errors":[{"error":"...","field":"..."}]}` | Yes (429) | Not documented | No |
| IFS OData | HTTPS/JSON | `{"error":{"code":"...","message":"...","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 `/services/data/v62.0/limits` endpoint | `[{"errorCode":"REQUEST_LIMIT_EXCEEDED","message":"..."}]` [src1] |
| SAP S/4HANA | Depends on API Mgmt | Depends on API Mgmt config | Only if SAP API Management is deployed | OData error JSON [src5] |
| 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 | `{"type":"https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.30","title":"Too Many Requests","status":429,"o:errorDetails":[{"detail":"...","o:errorCode":"CONCUR_LIMIT_EXCEEDED"}]}` [src3, src4] |
| NetSuite SOAP | 403 Forbidden | No | No | SOAP Fault: `SSS_REQUEST_LIMIT_EXCEEDED` [src3] |
| Dynamics 365 F&O | 429 Too Many Requests | Yes -- value in seconds | None standard | `{"error":{"code":"429","message":"Number of requests exceeded the limit of 6000 over time window of 300 seconds."}}` [src2] |
| Business Central | 429 Too Many Requests | Yes -- value in seconds | None standard | `{"error":{"code":"too_many_requests","message":"..."}}` [src5] |
| 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":"..."}}` [src6] |
| Acumatica | 500 (queue full) | No | No | `{"ExceptionMessage":"The request queue is full. Try again later."}` [src8] |

### Error Response Format Deep Dive

**Salesforce REST API** returns an array of error objects: [src1, src7]

```json
[
  {
    "errorCode": "REQUEST_LIMIT_EXCEEDED",
    "message": "TotalRequests Limit exceeded.",
    "fields": []
  }
]
```

**Salesforce Bulk API 2.0** provides separate success and failure result files per job. Failed records include: [src7]

```csv
"sf__Id","sf__Error","Name","Amount"
"","REQUIRED_FIELD_MISSING:Required fields are missing: [Name]","","50000"
"","DUPLICATES_DETECTED:Use one of these records?","Acme","75000"
```

**SAP S/4HANA OData v4** returns standard OData error format:

```json
{
  "error": {
    "code": "/IWBEP/CM_V4_RUNTIME/021",
    "message": "Resource not found for segment 'InvalidEntity'",
    "@SAP.severity": "error",
    "target": "InvalidEntity",
    "details": []
  }
}
```

**Oracle Fusion REST** returns RFC 7807 problem details:

```json
{
  "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** returns a structured error object: [src4]

```json
{
  "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** returns OData error with innererror chain: [src2]

```json
{
  "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",
      "stacktrace": "..."
    }
  }
}
```

**Business Central** returns compact OData error: [src5]

```json
{
  "error": {
    "code": "too_many_requests",
    "message": "Rate limit is exceeded. Reduce the frequency of requests."
  }
}
```

**IFS Cloud** returns OData-style error with details array: [src6]

```json
{
  "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** returns exception-style errors: [src8]

```json
{
  "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":"Session expired or invalid"}]` | Token expired, invalid connected app, IP restrictions [src1] |
| SAP S/4HANA | 401 / 403 | OData error JSON | Certificate mismatch, scope insufficient, XSRF token missing |
| Oracle Fusion | 401 Unauthorized | JSON `{"type":"...","title":"Unauthorized"}` | Basic auth credentials wrong, OAuth token expired |
| NetSuite | 401 Unauthorized | JSON error object | TBA token revoked, OAuth token expired, invalid consumer key [src4] |
| Dynamics 365 | 401 / 403 | OData error JSON | Entra ID token expired, insufficient app permissions [src2] |
| Business Central | 401 Unauthorized | OData error JSON | OAuth token expired, wrong tenant [src5] |
| 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 [src6] |
| Acumatica | 401 Unauthorized | JSON | Login limit exceeded, session expired [src8] |

### 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_ATTEMPT` even 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
<!-- Agents: read this section before recommending any integration approach from this unit.
     These are hard constraints that must not be violated. -->

- **Salesforce dual error systems**: Governor limit errors (SOQL limit, DML limit, CPU timeout) are synchronous transaction aborts with NO Retry-After header. Rate limit errors (daily quota) return 429 with Retry-After. These are completely separate systems and require different handling. [src1]
- **NetSuite dual HTTP codes**: REST returns 429 for concurrency/rate violations; SOAP returns 403. Both mean the same thing (concurrency limit exceeded) but require different HTTP status handling in middleware. [src3, src4]
- **SAP has no standard error format for throttling**: SAP S/4HANA Cloud itself does not throttle. Rate limiting only happens if SAP API Management or a third-party gateway is deployed. The error format depends entirely on which gateway is used.
- **Acumatica queues before rejecting**: Acumatica does not return 429. Instead, it queues up to 20 excess requests (with 1-minute timeout per queued request) and returns HTTP 500 when the queue is full. This means standard 429-based retry logic will not work. [src8]
- **Workday error opacity**: Workday REST API error formats are not comprehensively documented. Error shapes vary by endpoint and API version. Test every endpoint you plan to use.
- **IFS Cloud Oracle errors leak through**: IFS database errors surface raw Oracle error codes (ORA-xxxxx) in API responses. These are useful for debugging but should not be exposed to end users. [src6]
- **Business Central strict per-user throttling**: Limits are enforced per-user, per-environment. A single service account handling all integration traffic will hit limits faster than distributed service principals. [src5]

## 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, Dynamics 365 F&O, Business Central)
|   |   |   |   --> Use Retry-After value directly
|   |   |   +-- NO (Oracle, NetSuite, Workday, IFS)
|   |   |       --> Implement exponential backoff: 2^n seconds, max 60s, with jitter
|   |   +-- Does the ERP use 429? (check HTTP code)
|   |       +-- YES (all except SAP, Acumatica, NetSuite SOAP)
|   |       |   --> Standard 429 handler
|   |       +-- NO -- SAP: depends on gateway; check gateway docs
|   |       +-- NO -- Acumatica: returns 500 with queue-full message
|   |       +-- NO -- NetSuite SOAP: returns 403 for concurrency
|   |
|   +-- Business logic errors (400/422)
|   |   +-- Retryable? (e.g., record lock, temporary validation)
|   |   |   +-- YES --> Retry with backoff (3 attempts max)
|   |   |   +-- NO --> Log, skip, dead letter queue
|   |   +-- Does ERP give per-field error details?
|   |       +-- YES (Salesforce, Dynamics 365, IFS) --> Map to source fields
|   |       +-- NO (SAP, Workday) --> Parse error message text
|   |
|   +-- 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 in multipart response
|   |   +-- NetSuite SOAP --> Check writeResponseList status per record
|   |   +-- Dynamics 365 $batch --> Check each response in multipart body
|   |   +-- Acumatica --> Call process status endpoint separately
|   |   +-- Others --> No native partial success; implement at application level
|   |
|   +-- Server errors (500/503)
|       +-- Always retryable with exponential backoff
|       +-- SAP 503: server temporarily overloaded/maintenance
|       +-- Max 5 retries, then dead letter queue
|
+-- What's the error tolerance?
    +-- Zero-loss required --> 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 with o:errorDetails | OData JSON + innererror | OData JSON | JSON errors array | OData JSON + 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 array) | Partial (target) | No | Partial | Yes (innererror) | No | Yes (field) | Yes (details) | No |
| **Gov/transaction errors** | Yes (governor limits) | No | No | Yes (governance units) | Execution time pool | No | No | No | No |
| **Idempotency support** | Sforce-Call-Options header | 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 with jitter | Retry-After value | Retry-After value | 2^n with 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]

```javascript
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]

```javascript
function getRetryDelay(response, attempt, erpSystem) {
  // Check for standard Retry-After header (Salesforce, D365, Business Central)
  const retryAfter = response.headers.get('Retry-After');
  if (retryAfter) {
    const seconds = parseInt(retryAfter, 10);
    if (!isNaN(seconds)) return seconds * 1000;
    // Some ERPs return HTTP-date format
    const date = new Date(retryAfter);
    if (!isNaN(date)) return Math.max(0, date - Date.now());
  }

  // ERP-specific fallbacks
  const baseDelays = {
    salesforce: 2000,       // SF usually returns Retry-After, but fallback 2s
    sap: 3000,              // SAP has no standard; 3s base
    oracle_fusion: 5000,    // Oracle can be slow to recover
    netsuite: 2000,         // NetSuite concurrency frees quickly
    dynamics365: 2000,      // D365 usually returns Retry-After
    business_central: 2000, // BC usually returns Retry-After
    workday: 5000,          // Workday is opaque; be conservative
    ifs: 3000,              // IFS has no Retry-After
    acumatica: 60000,       // Acumatica queue timeout is 60s
  };

  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); // Cap at 2 minutes
}
```

**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]

```javascript
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') {
      // Re-authenticate and retry once
      options.headers.Authorization = await refreshToken(erpSystem);
      continue;
    }

    if (errorType === 'RATE_LIMITED') {
      if (attempt === maxRetries) throw new Error(`Rate limit exceeded after ${maxRetries} retries`);
      const delay = getRetryDelay(response, attempt, erpSystem);
      console.log(`[${erpSystem}] Rate limited. Retry ${attempt + 1}/${maxRetries} in ${delay}ms`);
      await new Promise(r => setTimeout(r, delay));
      continue;
    }

    if (errorType === 'SERVER_ERROR') {
      if (attempt === maxRetries) throw new Error(`Server error after ${maxRetries} retries: ${body?.message || response.statusText}`);
      const delay = getRetryDelay(response, attempt, erpSystem);
      await new Promise(r => setTimeout(r, delay));
      continue;
    }

    // CLIENT_ERROR or FORBIDDEN: not retryable
    throw new Error(`Non-retryable error from ${erpSystem}: ${JSON.stringify(body)}`);
  }
}
```

**Verify**: Mock a 429 response 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]

```javascript
async function handleBulkResult(erpSystem, jobId, response) {
  switch (erpSystem) {
    case 'salesforce_bulk': {
      // Salesforce Bulk API 2.0: 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': {
      // Composite: each subrequest has its own httpStatusCode
      const results = response.compositeResponse;
      return {
        failed: results.filter(r => r.httpStatusCode >= 400),
        succeeded: results.filter(r => r.httpStatusCode < 400)
      };
    }
    case 'sap_batch': {
      // SAP OData $batch: parse multipart response, each changeset independent
      // Status per changeset in Content-Type boundary sections
      return parseSapBatchResponse(response);
    }
    case 'netsuite_soap': {
      // SuiteTalk: writeResponseList has per-record status
      const records = response.writeResponseList.writeResponse;
      return {
        failed: records.filter(r => r.status.isSuccess === false),
        succeeded: records.filter(r => r.status.isSuccess === true)
      };
    }
    case 'dynamics365_batch': {
      // OData $batch: parse multipart, each response has status
      return parseDynamicsBatchResponse(response);
    }
    case 'acumatica': {
      // Acumatica: call process status endpoint separately
      const status = await fetch(`/entity/Default/24.200.001/${jobId}/status`);
      return await status.json();
    }
    default:
      // Oracle Fusion, Workday, IFS, Business Central: no native partial success
      // Must implement at application level with individual calls
      return { allOrNothing: true, response };
  }
}
```

**Verify**: Submit a Salesforce Bulk API job with intentional invalid records -> `failedResults` endpoint should return the specific records with error details.

## Code Examples

### Python: Universal ERP retry handler with system-specific logic

```python
# Input:  ERP system name, request URL, headers, optional body
# Output: Successful response or raises after max retries

import time
import random
import 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):
    """Make an HTTP request with ERP-aware retry logic."""
    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

        # Check if this is a retryable error
        is_retryable = resp.status_code in config["retry_codes"]

        # Acumatica special case: 500 with queue-full message
        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  # Real 500, not queue full

        if not is_retryable or attempt == config["max_retries"]:
            resp.raise_for_status()

        # Calculate delay: prefer Retry-After header, fall back to exponential backoff
        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)  # Add jitter
            delay = min(delay, 120)  # Cap at 2 minutes

        print(f"[{erp_system}] {resp.status_code} - Retry {attempt + 1}/{config['max_retries']} in {delay:.1f}s")
        time.sleep(delay)

    raise requests.exceptions.RetryError(f"Max retries exceeded for {erp_system}")
```

### JavaScript/Node.js: Parse ERP-specific error responses

```javascript
// Input:  HTTP response object, ERP system identifier
// Output: Normalized error object { code, message, retryable, fields, raw }

// Works with node-fetch@3.x or native fetch
function parseErpError(response, body, erpSystem) {
  const normalized = {
    httpStatus: response.status,
    code: 'UNKNOWN_ERROR',
    message: 'An unknown error occurred',
    retryable: false,
    fields: [],
    raw: body,
    erpSystem,
  };

  switch (erpSystem) {
    case 'salesforce':
      // Salesforce returns array of errors
      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 'sap':
      // SAP OData error structure
      normalized.code = body?.error?.code || 'UNKNOWN';
      normalized.message = body?.error?.message || '';
      normalized.retryable = response.status >= 500 || response.status === 429;
      break;

    case 'oracle_fusion':
      // Oracle RFC 7807 problem details
      normalized.code = body?.['o:errorCode'] || body?.code || 'UNKNOWN';
      normalized.message = body?.detail || body?.message || '';
      normalized.retryable = response.status === 429 || response.status >= 500;
      break;

    case 'netsuite':
      // NetSuite REST error structure
      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) ||
        ['CONCUR_LIMIT_EXCEEDED', 'SSS_REQUEST_LIMIT_EXCEEDED', 'WS_CONCUR_SESSION_DISALLWD'].includes(normalized.code);
      break;

    case 'dynamics365':
      // OData error with innererror chain
      normalized.code = body?.error?.code || 'UNKNOWN';
      normalized.message = body?.error?.message || '';
      normalized.retryable = response.status === 429 || response.status >= 500;
      if (body?.error?.innererror) {
        normalized.innerError = body.error.innererror.message;
      }
      break;

    case 'business_central':
      normalized.code = body?.error?.code || 'UNKNOWN';
      normalized.message = body?.error?.message || '';
      normalized.retryable = response.status === 429 || response.status >= 500;
      break;

    case 'workday':
      // Workday errors array
      const err = body?.errors?.[0] || {};
      normalized.code = err.error || 'UNKNOWN';
      normalized.message = err.error || '';
      normalized.fields = err.field ? [err.field] : [];
      normalized.retryable = response.status === 429 || response.status >= 500;
      break;

    case 'ifs':
      // IFS OData-style with details array
      normalized.code = body?.error?.code || 'UNKNOWN';
      normalized.message = body?.error?.message || '';
      if (body?.error?.details) {
        normalized.fields = body.error.details.map(d => d.message);
      }
      normalized.retryable = response.status >= 500;
      break;

    case 'acumatica':
      // Acumatica exception format
      normalized.code = body?.ExceptionType || 'UNKNOWN';
      normalized.message = body?.ExceptionMessage || '';
      normalized.retryable = response.status === 500 &&
        (normalized.message.includes('queue is full') || normalized.message.includes('try again'));
      break;
  }

  return normalized;
}
```

### cURL: Test error responses from each ERP

```bash
# === SALESFORCE: Trigger rate limit error ===
# Use an invalid/expired token to get error response format
curl -s -w "\nHTTP Status: %{http_code}\n" \
  -H "Authorization: Bearer EXPIRED_TOKEN" \
  "https://yourInstance.salesforce.com/services/data/v62.0/sobjects/Account"
# Expected: 401 [{"errorCode":"INVALID_SESSION_ID","message":"Session expired or invalid","fields":[]}]

# === SALESFORCE: Check remaining API limits ===
curl -s -H "Authorization: Bearer $SF_TOKEN" \
  "https://yourInstance.salesforce.com/services/data/v62.0/limits" \
  | jq '{DailyApiRequests, ConcurrentAsyncGetReportInstances}'

# === DYNAMICS 365 F&O: Check throttle status via response headers ===
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: {"type":"...","title":"Too Many Requests","status":429,"o:errorDetails":[...]}
```

## 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 [src1] |
| Salesforce | UNABLE_TO_LOCK_ROW | 400 | Record lock contention | Yes | Retry with jitter; avoid parallel updates to same record [src1] |
| Salesforce | GOVERNOR_LIMIT | 500 | Transaction limit exceeded | No | Bulkify code; reduce SOQL/DML per transaction [src1] |
| SAP | /IWBEP/CM_V4_RUNTIME/021 | 404 | Entity not found | No | Verify entity set name and API version |
| SAP | CX_SXML_PARSE_ERROR | 400 | Malformed request payload | No | Validate JSON/XML against schema |
| 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 [src3] |
| NetSuite | SSS_REQUEST_LIMIT_EXCEEDED | 429 | Per-user rate limit exceeded | Yes | Throttle request rate; check governance dashboard [src3] |
| D365 F&O | 429 | 429 | Service protection limit exceeded | Yes | Use Retry-After header value [src2] |
| D365 BC | too_many_requests | 429 | Per-user rate limit | Yes | Use Retry-After header value [src5] |
| Workday | 429 | 429 | Tenant rate limit | Yes | Exponential backoff with jitter |
| IFS | DATABASE_ERROR | 500 | Database constraint violation | No | Fix data; check mandatory fields [src6] |
| Acumatica | PX.Data.PXException | 500 | Queue full or business logic | Maybe | If queue-full: wait 60s and retry; otherwise: fix data [src8] |

### Failure Points in Production

- **Salesforce: Composite API silent subrequest failures**: When `allOrNone` is set to `false` (default), the Composite API returns HTTP 200 even when individual subrequests fail. Your code must check each `compositeResponse[n].httpStatusCode`. Fix: `Always iterate compositeResponse and handle per-subrequest errors, or set allOrNone: true for atomic behavior.` [src1, src7]
- **NetSuite: REST vs SOAP status code mismatch**: Middleware that only handles HTTP 429 will miss NetSuite SOAP concurrency violations (HTTP 403). Fix: `For NetSuite SOAP, treat 403 with SSS_REQUEST_LIMIT_EXCEEDED as equivalent to 429 and apply the same retry logic.` [src3, src4]
- **Dynamics 365: Execution time limit hits before request count**: The 1,200-second combined execution time in a 5-minute window is often exhausted before the 6,000 request limit on complex OData queries. Fix: `Simplify queries; use $select to reduce fields; monitor execution time in LCS telemetry.` [src2]
- **Acumatica: Queue timeout masquerades as server error**: When Acumatica's request queue is full, it returns HTTP 500 (not 429). Standard retry-on-429 logic never triggers. Fix: `Parse the ExceptionMessage for "queue is full" and treat it as a rate limit, not a server error. Wait 60 seconds before retry.` [src8]
- **SAP: Missing XSRF token causes 403 loop**: SAP OData requires an X-CSRF-Token for write operations. A missing or expired token returns 403. Middleware that treats 403 as "not authorized" will never retry correctly. Fix: `On any 403 from SAP, first try fetching a new X-CSRF-Token with a HEAD request before assuming an authorization failure.`
- **IFS: Oracle error codes in API responses**: IFS surfaces raw Oracle database errors (ORA-20124) in API responses. Integrations that parse only the top-level error code miss the detailed constraint violation information in the `details` array. Fix: `Always parse the details array in IFS error responses for the actual root cause.` [src6]

## Anti-Patterns

### Wrong: Using a single 429 handler for all ERPs

```javascript
// 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

```javascript
// 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); // Uses Retry-After when available
    await sleep(delay);
    return callErp(url, options, erp, attempt + 1);
  }
  return { resp, body };
}
```

### Wrong: Ignoring partial success in bulk operations

```javascript
// 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

```javascript
// 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) {
    // Route failures to dead letter queue for investigation
    await deadLetterQueue.push(failures.map(f => ({
      record: f,
      error: f.sf__Error,
      timestamp: new Date().toISOString()
    })));
  }
}
```

### Wrong: Treating SAP 403 as authorization failure

```javascript
// BAD -- SAP 403 often means missing XSRF token, not auth failure
if (resp.status === 403) {
  throw new Error('User not authorized for this operation');
  // Could be a missing X-CSRF-Token, not an auth problem!
}
```

### Correct: Check for XSRF token requirement on SAP 403

```javascript
// 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']) {
    // Fetch fresh XSRF 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

- **Cross-platform: Hardcoding Retry-After values**: Only Salesforce, Dynamics 365 F&O, and Business Central reliably return Retry-After headers. Hardcoding a 5-second delay for all ERPs wastes time (when the actual wait is shorter) or causes unnecessary failures (when the actual wait is longer). Fix: `Always parse Retry-After when present; fall back to exponential backoff with ERP-specific base delays.` [src1, src2, src5]
- **NetSuite: Not handling both 429 and 403**: Developers who only handle 429 miss SOAP concurrency violations (403). Fix: `For NetSuite, check the error code string (CONCUR_LIMIT_EXCEEDED, SSS_REQUEST_LIMIT_EXCEEDED) in addition to the HTTP status.` [src3, src4]
- **Acumatica: Standard retry logic never fires**: Acumatica returns 500 for queue-full, not 429. Standard retry-on-429 middleware silently passes through the error. Fix: `Parse Acumatica 500 responses for "queue is full" or "try again" messages and treat them as retryable.` [src8]
- **Salesforce: Confusing governor limits with rate limits**: Governor limits abort the current transaction synchronously (no retry possible for that transaction). Rate limits (429) allow retry after a delay. Fix: `Handle governor limit errors by reducing batch size or refactoring code; handle 429 with Retry-After delay. Never retry governor limit errors without changing the request.` [src1]
- **SAP: Expecting rate limit headers without API Management**: SAP S/4HANA Cloud has no built-in throttling or rate limit headers. Without SAP API Management, 429 responses never occur -- instead, the system slows down or returns 503 under load. Fix: `Deploy SAP API Management with spike arrest policies to get standardized 429 + Retry-After behavior.`
- **IFS: Ignoring the details array**: IFS error responses contain a top-level code and message, but the actual root cause is often in the `details` array (including raw Oracle error codes). Fix: `Always parse and log the full details array from IFS error responses.` [src6]
- **Dynamics 365: Not distributing load across service principals**: Business Central and Dynamics 365 F&O enforce per-user limits. A single integration service account hits limits much faster than distributed service principals. Fix: `Create multiple service principals and distribute integration traffic across them.` [src2, src5]

## Diagnostic Commands

```bash
# === 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}, ConcurrentAsyncGetReportInstances}'
# Returns: { "DailyApiRequests": { "Max": 100000, "Remaining": 87234 }, ... }

# === 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"
# Returns CSV with sf__Id, sf__Error columns for each failed record

# === NETSUITE: Check concurrency governance status ===
# Navigate: Setup > Integration > Integration Management > Integration Governance
# Or query via SuiteQL:
curl -X POST "https://ACCOUNT_ID.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql" \
  -H "Authorization: OAuth ..." \
  -H "Content-Type: application/json" \
  -d '{"q": "SELECT * FROM integrationGovernance WHERE status = '\''ACTIVE'\''"}'

# === DYNAMICS 365 F&O: Monitor throttle events ===
# LCS > Environment Monitoring > Raw Logs > Filter: ThrottledRequests
# Or check the response headers on any API call:
curl -s -D - -o /dev/null -H "Authorization: Bearer $D365_TOKEN" \
  "https://your-instance.operations.dynamics.com/data/Customers?\$top=1"
# Look for: x-ms-ratelimit-remaining-*, Retry-After headers

# === 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}'

# === ACUMATICA: Check session and queue status ===
curl -s -H "Authorization: Bearer $ACU_TOKEN" \
  "https://your-instance.acumatica.com/entity/Default/24.200.001/StockItem?\$top=1" \
  -w "\nHTTP Status: %{http_code}\n"
# If 500 with "queue is full": wait 60s and retry
```

## 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 |
| **Error Code Field Name** | errorCode | error.code | o:errorCode | o:errorCode | error.code | error.code | errors[].error | error.code | ExceptionType |
| **Field-Level Error Info** | Yes (fields[]) | Partial (target) | No | Partial | Yes (innererror) | No | Yes (field) | Yes (details[]) | No |
| **Partial Success (Bulk)** | Yes (Bulk API 2.0, Composite) | Yes ($batch) | No | Yes (SOAP writeResponseList) | Yes ($batch) | No | No | No | Separate status endpoint |
| **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 |
| **Governor/Transaction Errors** | Yes (extensive) | No | No | Yes (governance units) | Execution time pool | No | No | No | No |
| **Error Documentation Quality** | Excellent | Good (OData standard) | Moderate | Good | Good | Good | Poor | Moderate | Moderate |
| **Rate Limit Transparency** | Excellent (API endpoint) | Low | Low | Good (admin panel) | Good (LCS telemetry) | Good | Poor | Low | Low |
| **Recommended Backoff Strategy** | Retry-After value | Exponential 2^n | Exponential 2^n, max 60s | Exponential 2^n + jitter | Retry-After value | Retry-After value | Exponential 2^n + jitter | Exponential 2^n | Fixed 60s 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 | business/erp-integration/erp-rate-limits-comparison/2026 |
| Building a universal ERP integration adapter | Need dead letter queue architecture patterns | business/erp-integration/error-handling-dead-letter-queues/2026 |
| Debugging why retry logic works on one ERP but not another | Need authentication flow details | business/erp-integration/erp-authentication-comparison/2026 |
| Comparing ERPs for integration resilience and developer experience | Need single-system deep dive | System-specific API capability card |
| Implementing observability/monitoring across ERP integrations | Need bulk import comparison | business/erp-integration/erp-bulk-import-comparison/2026 |

## 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. The error formats shown here assume SAP API Management or a similar gateway is deployed.
- Workday error documentation is the least comprehensive of all nine platforms. The error shapes shown here are based on community observation and may vary by API version and endpoint.
- Acumatica's queue-full behavior (HTTP 500 instead of 429) is by design, not a bug. Their architecture queues requests instead of immediately rejecting them, which means 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. Any middleware handling both REST and SOAP NetSuite endpoints must account for both codes.
- IFS Cloud surfaces raw Oracle database error codes (ORA-xxxxx) in API responses. These are implementation details that could change with database migrations but are currently the most reliable way to identify the root cause of errors.
- All error formats and response structures shown are based on current stable releases as of early 2026. Check vendor release notes for any changes in your specific version.

## Related Units

- [ERP Rate Limits Comparison](/business/erp-integration/erp-rate-limits-comparison/2026)
- [ERP REST API Capabilities Comparison](/business/erp-integration/erp-rest-api-comparison/2026)
- [ERP Authentication Comparison](/business/erp-integration/erp-authentication-comparison/2026)
- [Error Handling & Dead Letter Queues](/business/erp-integration/error-handling-dead-letter-queues/2026)
- [ERP Bulk Import Comparison](/business/erp-integration/erp-bulk-import-comparison/2026)
