Skip to content

Commit cfd4121

Browse files
authored
Merge pull request #8098 from bakaphp/hotfix/netsuite-rate-limit
fix: netsuite rate limit
2 parents da0a072 + a736092 commit cfd4121

12 files changed

+225
-114
lines changed

src/Domains/Connectors/NetSuite/Actions/SyncCompanyWithNetSuiteAction.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Kanvas\Connectors\NetSuite\Traits\UseNetSuiteCustomerTrait;
1313
use NetSuite\Classes\Customer;
1414
use NetSuite\Classes\UpdateRequest;
15+
use NetSuite\NetSuiteService;
1516

1617
class SyncCompanyWithNetSuiteAction
1718
{
@@ -21,7 +22,7 @@ public function __construct(
2122
protected AppInterface $app,
2223
protected Companies $company
2324
) {
24-
$this->service = (new Client($app, $company))->getService();
25+
$this->client = new Client($app, $company);
2526
}
2627

2728
public function execute(): Companies
@@ -57,7 +58,9 @@ protected function updateExistingCustomer(): Companies
5758
$updateRequest = new UpdateRequest();
5859
$updateRequest->record = $customer;
5960

60-
$updateResponse = $this->service->update($updateRequest);
61+
$updateResponse = $this->client->executeWithRateLimit(function (NetSuiteService $service) use ($updateRequest) {
62+
return $service->update($updateRequest);
63+
});
6164

6265
if (! $updateResponse->writeResponse->status->isSuccess) {
6366
throw new Exception(

src/Domains/Connectors/NetSuite/Actions/SyncPeopleWithNetSuiteAction.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Kanvas\Guild\Customers\Models\People;
1313
use NetSuite\Classes\Customer;
1414
use NetSuite\Classes\UpdateRequest;
15+
use NetSuite\NetSuiteService;
1516

1617
class SyncPeopleWithNetSuiteAction
1718
{
@@ -21,7 +22,7 @@ public function __construct(
2122
protected AppInterface $app,
2223
protected People $people
2324
) {
24-
$this->service = (new Client($app, $people->company))->getService();
25+
$this->client = new Client($app, $people->company);
2526
}
2627

2728
public function execute(): People
@@ -58,10 +59,9 @@ protected function updateExistingCustomer(): People
5859
$updateRequest = new UpdateRequest();
5960
$updateRequest->record = $customer;
6061

61-
/**
62-
* @todo update phone , email, address, custom fields
63-
*/
64-
$updateResponse = $this->service->update($updateRequest);
62+
$updateResponse = $this->client->executeWithRateLimit(function (NetSuiteService $service) use ($updateRequest) {
63+
return $service->update($updateRequest);
64+
});
6565

6666
if (! $updateResponse->writeResponse->status->isSuccess) {
6767
throw new Exception(

src/Domains/Connectors/NetSuite/Client.php

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,39 @@
66

77
use Baka\Contracts\AppInterface;
88
use Baka\Contracts\CompanyInterface;
9+
use Closure;
10+
use Illuminate\Support\Facades\Cache;
911
use Kanvas\Connectors\NetSuite\Enums\ConfigurationEnum;
1012
use Kanvas\Exceptions\ValidationException;
1113
use NetSuite\NetSuiteService;
14+
use SoapFault;
15+
use Throwable;
1216

1317
class Client
1418
{
1519
protected string $apiUrl;
1620
protected string $endPoint = '2021_1';
21+
protected ?NetSuiteService $service = null;
22+
23+
// Max concurrent requests (NetSuite limit is 4, default 2 to leave room for other integrations)
24+
protected int $maxConcurrentRequests;
25+
protected int $lockTimeoutSeconds = 120;
26+
protected int $maxRetries = 5;
1727

1828
public function __construct(
1929
protected AppInterface $app,
2030
protected CompanyInterface $company
2131
) {
2232
$this->apiUrl = $app->get(ConfigurationEnum::NET_SUITE_CUSTOM_API_URL->value) ?? 'https://webservices.netsuite.com';
33+
$this->maxConcurrentRequests = (int) ($app->get(ConfigurationEnum::NET_SUITE_MAX_CONCURRENT_REQUESTS->value) ?? 2);
2334
}
2435

2536
public function getService(): NetSuiteService
2637
{
38+
if ($this->service !== null) {
39+
return $this->service;
40+
}
41+
2742
$config = $this->app->get(ConfigurationEnum::NET_SUITE_ACCOUNT_CONFIG->value);
2843

2944
if (empty($config)) {
@@ -47,6 +62,78 @@ public function getService(): NetSuiteService
4762
'log_dateformat' => 'Ymd.His.u',
4863
];
4964

50-
return new NetSuiteService($config);
65+
$this->service = new NetSuiteService($config);
66+
67+
return $this->service;
68+
}
69+
70+
/**
71+
* Execute a NetSuite operation with rate limiting using Redis semaphore.
72+
* This ensures we never exceed the concurrent request limit (max 4 in NetSuite).
73+
*/
74+
public function executeWithRateLimit(Closure $operation): mixed
75+
{
76+
$lockKey = $this->getLockKey();
77+
$attempt = 0;
78+
79+
while ($attempt < $this->maxRetries) {
80+
// Try to acquire one of the available slots
81+
for ($slot = 0; $slot < $this->maxConcurrentRequests; $slot++) {
82+
$slotKey = "{$lockKey}:slot:{$slot}";
83+
$lock = Cache::lock($slotKey, $this->lockTimeoutSeconds);
84+
85+
if ($lock->get()) {
86+
try {
87+
$result = $operation($this->getService());
88+
$lock->forceRelease();
89+
90+
return $result;
91+
} catch (SoapFault $e) {
92+
$lock->forceRelease();
93+
94+
if ($this->isRateLimitError($e)) {
95+
$attempt++;
96+
// Exponential backoff: 2s, 4s, 8s
97+
sleep(pow(2, $attempt));
98+
99+
continue 2; // Break out and retry the while loop
100+
}
101+
102+
throw $e;
103+
} catch (Throwable $e) {
104+
$lock->forceRelease();
105+
106+
throw $e;
107+
}
108+
}
109+
}
110+
111+
// No slot available, wait and retry
112+
$attempt++;
113+
// Shorter wait: 1s, 2s, 3s
114+
sleep($attempt);
115+
}
116+
117+
throw new ValidationException(
118+
"NetSuite rate limit: Could not acquire API slot after {$this->maxRetries} attempts. Check for stale locks in Redis with key: {$lockKey}"
119+
);
120+
}
121+
122+
protected function getLockKey(): string
123+
{
124+
// Use NetSuite account ID as lock key so all apps sharing the same NS account share the lock pool
125+
$config = $this->app->get(ConfigurationEnum::NET_SUITE_ACCOUNT_CONFIG->value);
126+
$accountId = $config['account'] ?? $this->app->getId();
127+
128+
return 'netsuite_rate_limit:' . $accountId;
129+
}
130+
131+
protected function isRateLimitError(SoapFault $e): bool
132+
{
133+
$message = strtolower($e->getMessage());
134+
135+
return str_contains($message, 'concurrent request limit exceeded')
136+
|| str_contains($message, 'request blocked')
137+
|| str_contains($message, 'suitetalk concurrent request limit');
51138
}
52139
}

src/Domains/Connectors/NetSuite/Enums/ConfigurationEnum.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ enum ConfigurationEnum: string
1111
case NET_SUITE_MINIMUM_PRODUCT_QUANTITY = 'NET_SUITE_SET_B2B_MINIMUM_PRODUCT_QUANTITY';
1212
case NET_SUITE_DEFAULT_WAREHOUSE = 'NET_SUITE_DEFAULT_WAREHOUSE';
1313
case NET_SUITE_USE_LEGACY_PRODUCT_SEARCH = 'NET_SUITE_USE_LEGACY_PRODUCT_SEARCH';
14+
case NET_SUITE_MAX_CONCURRENT_REQUESTS = 'NET_SUITE_MAX_CONCURRENT_REQUESTS';
1415
}

src/Domains/Connectors/NetSuite/Services/NetSuiteCustomerSearchService.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616

1717
class NetSuiteCustomerSearchService
1818
{
19-
protected NetSuiteService $service;
19+
protected Client $client;
2020

2121
public function __construct(
2222
protected AppInterface $app,
2323
protected CompanyInterface $company
2424
) {
25-
$this->service = (new Client($app, $company))->getService();
25+
$this->client = new Client($app, $company);
2626
}
2727

2828
/**
@@ -52,7 +52,9 @@ protected function executeAdvancedSearch(CustomerSearchAdvanced $search): array
5252
$searchRequest = new SearchRequest();
5353
$searchRequest->searchRecord = $search;
5454

55-
$response = $this->service->search($searchRequest);
55+
$response = $this->client->executeWithRateLimit(function (NetSuiteService $service) use ($searchRequest) {
56+
return $service->search($searchRequest);
57+
});
5658

5759
if (! $response->searchResult->status->isSuccess) {
5860
throw new Exception('Error searching customers: ' . $response->searchResult->status->statusDetail[0]->message);
@@ -74,7 +76,9 @@ protected function executeAdvancedSearch(CustomerSearchAdvanced $search): array
7476
$searchMoreRequest->searchId = $searchId;
7577
$searchMoreRequest->pageIndex = $pageIndex;
7678

77-
$moreResponse = $this->service->searchMoreWithId($searchMoreRequest);
79+
$moreResponse = $this->client->executeWithRateLimit(function (NetSuiteService $service) use ($searchMoreRequest) {
80+
return $service->searchMoreWithId($searchMoreRequest);
81+
});
7882

7983
if ($moreResponse->searchResult->status->isSuccess && isset($moreResponse->searchResult->searchRowList->searchRow)) {
8084
$moreCustomers = $this->mapSearchResultToCustomer($moreResponse->searchResult->searchRowList->searchRow);

src/Domains/Connectors/NetSuite/Services/NetSuiteCustomerService.php

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,51 +20,53 @@
2020

2121
class NetSuiteCustomerService
2222
{
23-
protected NetSuiteService $service;
23+
protected Client $client;
2424

2525
public function __construct(
2626
protected AppInterface $app,
2727
protected CompanyInterface $company
2828
) {
29-
$this->service = (new Client($app, $company))->getService();
29+
$this->client = new Client($app, $company);
3030
}
3131

3232
public function getCustomerById(int|string $customerId): Customer
3333
{
3434
$customerRef = new RecordRef();
3535
$customerRef->internalId = $customerId;
36-
$customerRef->type = 'customer'; // Add the record type here
36+
$customerRef->type = 'customer';
3737

3838
$getRequest = new GetRequest();
3939
$getRequest->baseRef = $customerRef;
4040

41-
$response = $this->service->get($getRequest);
41+
return $this->client->executeWithRateLimit(function (NetSuiteService $service) use ($getRequest) {
42+
$response = $service->get($getRequest);
43+
44+
if ($response->readResponse->status->isSuccess) {
45+
return $response->readResponse->record;
46+
}
4247

43-
if ($response->readResponse->status->isSuccess) {
44-
return $response->readResponse->record;
45-
} else {
4648
throw new ModelNotFoundException('Error retrieving customer: ' . $response->readResponse->status->statusDetail[0]->message);
47-
}
49+
});
4850
}
4951

5052
public function getInvoiceByNumber(string|int $invoiceNumber): array
5153
{
5254
$search = new TransactionSearch();
5355
$searchBasic = new TransactionSearchBasic();
5456

55-
// Add filter for Invoice Number (tranId)
5657
$searchBasic->tranId = new SearchStringField();
57-
$searchBasic->tranId->operator = 'is'; // Exact match
58+
$searchBasic->tranId->operator = 'is';
5859
$searchBasic->tranId->searchValue = $invoiceNumber;
5960

6061
$search->basic = $searchBasic;
6162

62-
// Wrap the TransactionSearch in a SearchRequest
6363
$searchRequest = new SearchRequest();
6464
$searchRequest->searchRecord = $search;
6565

66-
// Execute the search
67-
$response = $this->service->search($searchRequest);
66+
// Execute the search with rate limiting
67+
$response = $this->client->executeWithRateLimit(function (NetSuiteService $service) use ($searchRequest) {
68+
return $service->search($searchRequest);
69+
});
6870

6971
if (! $response->searchResult->status->isSuccess) {
7072
throw new ModelNotFoundException('Error retrieving invoice: ' . $response->searchResult->status->statusDetail[0]->message);
@@ -78,20 +80,21 @@ public function getInvoiceByNumber(string|int $invoiceNumber): array
7880
$getRequest->baseRef->internalId = $invoice->internalId;
7981
$getRequest->baseRef->type = RecordType::invoice;
8082

81-
$transactionResponse = $this->service->get($getRequest);
83+
// Get invoice details with rate limiting
84+
$transactionResponse = $this->client->executeWithRateLimit(function (NetSuiteService $service) use ($getRequest) {
85+
return $service->get($getRequest);
86+
});
8287

8388
if ($transactionResponse->readResponse->status->isSuccess) {
8489
$transactionDetail = $transactionResponse->readResponse->record;
8590
$invoiceDate = $transactionDetail->tranDate;
8691
$totalAmount = $transactionDetail->total;
8792
$customerName = $transactionDetail->entity->name ?? 'Unknown Customer';
8893

89-
// Add a header with customer name and invoice numberq
9094
$csvData[] = ["Customer Name: $customerName"];
9195
$csvData[] = ["Invoice Number: $invoiceNumber"];
92-
$csvData[] = []; // Empty row for spacing
96+
$csvData[] = [];
9397

94-
// Add the column headers
9598
$csvData[] = [
9699
'Invoice Number',
97100
'Date',
@@ -105,12 +108,11 @@ public function getInvoiceByNumber(string|int $invoiceNumber): array
105108
'Amount',
106109
];
107110

108-
$itemSubtotal = 0; // Initialize subtotal for the invoice
109-
$itemCount = 0; // Count items in the invoice
111+
$itemSubtotal = 0;
112+
$itemCount = 0;
110113

111114
if (! empty($transactionDetail->itemList->item)) {
112115
foreach ($transactionDetail->itemList->item as $item) {
113-
// Extract custom fields
114116
$customDescription = 'N/A';
115117
$customValue = 'N/A';
116118

@@ -129,21 +131,19 @@ public function getInvoiceByNumber(string|int $invoiceNumber): array
129131
$invoiceNumber,
130132
$invoiceDate,
131133
$totalAmount,
132-
$item->item->name ?? 'N/A', // Item Name
133-
$customDescription, // Item Description from custom field
134-
$item->class->name ?? 'N/A', // Class
135-
$customValue, // Additional custom field value
136-
$item->quantity ?? 0, // Quantity
137-
$item->rate ?? 0.00, // Rate
138-
$item->amount ?? 0.00, // Amount
134+
$item->item->name ?? 'N/A',
135+
$customDescription,
136+
$item->class->name ?? 'N/A',
137+
$customValue,
138+
$item->quantity ?? 0,
139+
$item->rate ?? 0.00,
140+
$item->amount ?? 0.00,
139141
];
140142

141-
// Accumulate totals
142143
$itemSubtotal += $item->amount ?? 0.00;
143144
$itemCount += $item->quantity ?? 0;
144145
}
145146

146-
// Add a summary row for the invoice
147147
$csvData[] = [
148148
$invoiceNumber,
149149
'Summary',
@@ -152,12 +152,11 @@ public function getInvoiceByNumber(string|int $invoiceNumber): array
152152
'',
153153
'',
154154
'',
155-
$itemCount, // Total Quantity
155+
$itemCount,
156156
'',
157-
$itemSubtotal, // Total Amount for the invoice
157+
$itemSubtotal,
158158
];
159159
} else {
160-
// No items case
161160
$csvData[] = [
162161
$invoiceNumber,
163162
$invoiceDate,

0 commit comments

Comments
 (0)