@fleetbase/fleetops-engine 0.6.66 → 0.6.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/RELEASE.md +15 -8
  2. package/addon/components/telematic/details.hbs +1 -1
  3. package/addon/components/telematic/telemetry-status.hbs +93 -34
  4. package/addon/components/widget/radar.hbs +1 -1
  5. package/addon/components/widget/radar.js +7 -0
  6. package/composer.json +2 -1
  7. package/docs/AFAQY.md +71 -17
  8. package/docs/SAFEE.md +125 -0
  9. package/docs/TELEMATICS_QUEUES.md +179 -0
  10. package/extension.json +1 -1
  11. package/package.json +1 -1
  12. package/server/config/safee.php +10 -0
  13. package/server/config/telematics.php +8 -1
  14. package/server/config/telemetry.php +4 -1
  15. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  16. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  17. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  18. package/server/src/Events/TrailerLocationChanged.php +2 -0
  19. package/server/src/Events/VehicleLocationChanged.php +5 -0
  20. package/server/src/Jobs/PollTelematicTelemetry.php +127 -12
  21. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  22. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  23. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  24. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  25. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  26. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  27. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  28. package/server/src/Support/Telematics/TelematicService.php +43 -1
  29. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  30. package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -3
  31. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  32. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  33. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  34. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +461 -0
  35. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  36. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  37. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  38. package/server/tests/ProviderContractsTest.php +50 -1
  39. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  40. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  41. package/server/tests/TelematicsHardeningTest.php +18 -13
  42. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  43. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  44. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  45. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  46. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  47. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
@@ -38,7 +38,8 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
38
38
 
39
39
  public function handle(Ingestor $ingestor, TelematicService $service): void
40
40
  {
41
- $lock = Cache::lock('telemetry:delivery:' . $this->deliveryUuid, 90);
41
+ $started = microtime(true);
42
+ $lock = Cache::lock('telemetry:delivery:' . $this->deliveryUuid, 90);
42
43
  if (!$lock->get()) {
43
44
  return;
44
45
  }
@@ -66,21 +67,48 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
66
67
  return;
67
68
  }
68
69
  $this->update(['status' => 'processing', 'attempts' => $row->attempts + 1, 'available_at' => now()->addSeconds(120)]);
70
+ $failed = [];
71
+ $failureTypes = [];
69
72
  try {
70
- $units = $provider->telemetryUnits(json_decode(Crypt::decryptString($row->retry_payload ?? $row->payload), true, 512, JSON_THROW_ON_ERROR));
73
+ $payload = json_decode(Crypt::decryptString($row->retry_payload ?? $row->payload), true, 512, JSON_THROW_ON_ERROR);
74
+ if ($row->retry_payload && ($payload['checkpoint_version'] ?? null) === 1) {
75
+ // This envelope is internal and encrypted; the original webhook
76
+ // contract was already validated before creating the checkpoint.
77
+ $units = $payload['remaining'] ?? null;
78
+ $failed = $payload['failed'] ?? null;
79
+ $failureTypes = $payload['failure_types'] ?? null;
80
+ foreach ([$units, $failed, $failureTypes] as $items) {
81
+ if (!is_array($items) || !array_is_list($items)) {
82
+ throw new \InvalidArgumentException('Invalid delivery checkpoint.');
83
+ }
84
+ }
85
+ } elseif ($row->source === 'poll') {
86
+ // Polling persists the provider's inventory records, including units
87
+ // that have never reported a position. Validate this internal envelope
88
+ // here and let ingestion report invalid positions per unit below.
89
+ if (!is_array($payload) || !array_is_list($payload) || $payload === []) {
90
+ throw new \InvalidArgumentException('Expected a non-empty polling unit list.');
91
+ }
92
+ $units = $payload;
93
+ } else {
94
+ // External deliveries must still pass the provider's position contract.
95
+ $units = $provider->telemetryUnits($payload);
96
+ }
71
97
  } catch (\Throwable) {
72
98
  $this->update(['status' => 'quarantined', 'failed' => 1, 'error' => 'Unsupported or unreadable position payload; inspect and replay after adapter correction.']);
73
99
  Inbox::finishRun($row->run_uuid);
74
100
 
75
101
  return;
76
102
  }
77
- $failed = [];
78
- $failureTypes = [];
79
103
  $applied = 0;
80
104
  $invalid = (int) $row->invalid_count;
81
- $sourceDelay = null;
105
+ // Polled inventory legitimately includes units without a valid fix (for example,
106
+ // never reported). They stay counted, but only pushed samples are failures to replay.
107
+ $pushed = $row->source !== 'poll';
108
+ $sourceDelay = $row->source_delay_seconds;
82
109
  $queueDelay = max(0, now()->timestamp - \Illuminate\Support\Carbon::parse($row->received_at, 'UTC')->timestamp);
83
- foreach ($units as $unit) {
110
+ $checkpointAt = $started;
111
+ foreach ($units as $index => $unit) {
84
112
  try {
85
113
  $result = $ingestor->ingest($telematic, $provider, $unit, $service, $row->received_at, $row->source);
86
114
  $sourceDelay = max($sourceDelay ?? 0, data_get($result['device']->meta, 'telemetry.source_delay_seconds', 0));
@@ -93,19 +121,45 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
93
121
  $failed[] = $unit;
94
122
  $failureTypes[] = class_basename($e);
95
123
  }
124
+ $elapsed = microtime(true);
125
+ $yield = $elapsed - $started >= 40 && $index + 1 < count($units);
126
+ if ($yield || ($index + 1) % 10 === 0 || $elapsed - $checkpointAt >= 2) {
127
+ // Persist progress independently of the worker reservation. A
128
+ // restart resumes the remaining tail rather than replaying a
129
+ // growing prefix until the delivery exhausts its retry budget.
130
+ $this->update([
131
+ 'retry_payload' => Crypt::encryptString(json_encode([
132
+ 'checkpoint_version' => 1, 'remaining' => array_slice($units, $index + 1),
133
+ 'failed' => $failed, 'failure_types' => array_values(array_unique($failureTypes)),
134
+ ], JSON_THROW_ON_ERROR)),
135
+ 'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
136
+ 'queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay,
137
+ 'status' => $yield ? 'retry' : 'processing',
138
+ // Cooperative continuation is not a failed attempt. Actual
139
+ // crashes keep the increment persisted at the start above.
140
+ 'attempts' => $yield ? $row->attempts : $row->attempts + 1,
141
+ 'available_at' => $yield ? now() : now()->addSeconds(120),
142
+ ]);
143
+ $checkpointAt = $elapsed;
144
+ }
145
+ if ($yield) {
146
+ // The normal inbox drain queues the continuation after this
147
+ // job releases its uniqueness lease; it uses the same queue.
148
+ return;
149
+ }
96
150
  }
97
151
  $this->update(['queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay]);
98
152
  if ($failed && $row->attempts < 4) {
99
153
  $this->update([
100
154
  'status' => 'retry', 'retry_payload' => Crypt::encryptString(json_encode($failed, JSON_THROW_ON_ERROR)),
101
- 'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
155
+ 'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
102
156
  'error' => 'Some units failed ingestion; retry scheduled. ' . implode(', ', array_unique($failureTypes)), 'available_at' => now()->addSeconds(15 * (2 ** $row->attempts)),
103
157
  ]);
104
158
  } else {
105
159
  $this->update([
106
- 'status' => ($failed || $invalid) ? 'quarantined' : 'processed',
107
- 'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
108
- 'error' => ($failed || $invalid) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
160
+ 'status' => ($failed || ($pushed ? $invalid : 0)) ? 'quarantined' : 'processed',
161
+ 'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
162
+ 'error' => ($failed || ($pushed ? $invalid : 0)) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
109
163
  'processed_at' => now(),
110
164
  ]);
111
165
  }
@@ -40,6 +40,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
40
40
  $this->telematic = $telematic;
41
41
  $this->options = $options;
42
42
  $this->jobId = $jobId ?? \Illuminate\Support\Str::uuid()->toString();
43
+ $this->onQueue(\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::pollQueue());
43
44
  }
44
45
 
45
46
  /**
@@ -47,6 +48,24 @@ class SyncTelematicDevicesJob implements ShouldQueue
47
48
  */
48
49
  public function handle(TelematicProviderRegistry $registry, TelematicService $service): void
49
50
  {
51
+ // Jobs queued before a provider opts into batching must use the same bounded
52
+ // path as new manual requests, without acquiring the legacy hour-long lock.
53
+ $provider = $registry->resolve($this->telematic->provider);
54
+ if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
55
+ && (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::options($provider)['manual_batch_sync'] ?? false)) {
56
+ $this->telematic->refresh();
57
+ if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Inbox::enabled($this->telematic)) {
58
+ try {
59
+ $service->queueTelemetrySync($this->telematic, $this->options, $this->jobId);
60
+ } catch (\Illuminate\Validation\ValidationException) {
61
+ // Paused or already queued/running. Failing here would mark the connection
62
+ // as errored and detach the active request's progress tracking.
63
+ Log::info('Skipped legacy telematics sync redirect.', ['telematic_uuid' => $this->telematic->uuid]);
64
+ }
65
+ }
66
+
67
+ return;
68
+ }
50
69
  $correlationId = \Illuminate\Support\Str::uuid()->toString();
51
70
  $lockKey = 'fleetops:sync-telematic-devices:' . $this->telematic->uuid;
52
71
  $lock = Cache::lock($lockKey, $this->timeout + 60);
@@ -96,12 +115,54 @@ class SyncTelematicDevicesJob implements ShouldQueue
96
115
  $totalEnrichment = 0;
97
116
  $totalEnrichmentCompleted = 0;
98
117
  $totalEnrichmentFailures = 0;
118
+ $itemsSinceCheckpoint = 0;
119
+ $lastCheckpointAt = now();
120
+
121
+ $checkpoint = function (string $phase, bool $force = false) use (
122
+ &$itemsSinceCheckpoint, &$lastCheckpointAt, &$providerSyncMeta, &$totalFetched,
123
+ &$totalLinked, &$totalLinkAttempts, &$totalEvents, &$totalSensors, &$totalSkipped,
124
+ &$pageCount, &$lastProviderAllCount, &$lastProviderFiltersCount,
125
+ &$inventoryFetched, &$inventoryLinked, &$inventorySkipped,
126
+ &$totalEnrichment, &$totalEnrichmentCompleted, &$totalEnrichmentFailures
127
+ ): void {
128
+ if (!$force && $itemsSinceCheckpoint < 25 && $lastCheckpointAt->diffInSeconds(now(), true) < 5) {
129
+ return;
130
+ }
131
+ $this->telematic->status = 'synchronizing';
132
+ $this->telematic->meta = array_merge($this->telematic->meta ?? [], $providerSyncMeta, [
133
+ 'last_sync_job_id' => $this->jobId,
134
+ 'last_sync_result' => 'running',
135
+ 'last_sync_phase' => $phase,
136
+ 'last_sync_progress_at' => now()->toDateTimeString(),
137
+ 'last_sync_fetched_total' => $totalFetched,
138
+ 'last_sync_linked_total' => $totalLinked,
139
+ 'last_sync_link_attempts_total' => $totalLinkAttempts,
140
+ 'last_sync_events_total' => $totalEvents,
141
+ 'last_sync_sensors_total' => $totalSensors,
142
+ 'last_sync_skipped_total' => $totalSkipped,
143
+ 'last_sync_page_count' => $pageCount,
144
+ 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount,
145
+ 'last_sync_provider_all_count' => $lastProviderAllCount,
146
+ 'last_sync_provider_filters_count' => $lastProviderFiltersCount,
147
+ 'last_sync_inventory_total' => $inventoryFetched,
148
+ 'last_sync_inventory_linked_total' => $inventoryLinked,
149
+ 'last_sync_inventory_skipped_total' => $inventorySkipped,
150
+ 'last_sync_enrichment_total' => $totalEnrichment,
151
+ 'last_sync_enrichment_completed' => $totalEnrichmentCompleted,
152
+ 'last_sync_enrichment_failures' => $totalEnrichmentFailures,
153
+ ]);
154
+ $this->telematic->save();
155
+ $itemsSinceCheckpoint = 0;
156
+ $lastCheckpointAt = now();
157
+ };
99
158
 
100
159
  try {
160
+ $checkpoint('connecting', true);
101
161
  $provider = $registry->resolve($this->telematic->provider);
102
162
  $provider->connect($this->telematic);
103
163
 
104
164
  do {
165
+ $checkpoint('fetching_inventory', true);
105
166
  $response = $provider->fetchDevices([
106
167
  'limit' => $this->options['limit'] ?? null,
107
168
  'cursor' => $cursor,
@@ -137,6 +198,9 @@ class SyncTelematicDevicesJob implements ShouldQueue
137
198
  'has_more' => $response['has_more'] ?? false,
138
199
  ]);
139
200
 
201
+ // Persist retrieval before any potentially slow per-device database work.
202
+ $inventoryFetched = $totalFetched;
203
+ $checkpoint('ingesting_inventory', true);
140
204
  foreach ($devices as $devicePayload) {
141
205
  $normalizedDevice = $provider->normalizeDevice($devicePayload);
142
206
  try {
@@ -162,7 +226,12 @@ class SyncTelematicDevicesJob implements ShouldQueue
162
226
  'imei' => $devicePayload['imei'] ?? null,
163
227
  ]);
164
228
  }
229
+ $inventoryLinked = $totalLinked;
230
+ $inventorySkipped = $totalSkipped;
231
+ $itemsSinceCheckpoint++;
232
+ $checkpoint('ingesting_inventory');
165
233
  }
234
+ $checkpoint('ingesting_inventory', true);
166
235
 
167
236
  $cursor = $response['next_cursor'] ?? null;
168
237
 
@@ -221,6 +290,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
221
290
  'device_count' => count($inventoryPayloads),
222
291
  ]);
223
292
 
293
+ $checkpoint('fetching_enrichment', true);
224
294
  $enrichmentResponse = $provider->fetchDeviceTelemetrySnapshots($inventoryPayloads, [
225
295
  'limit' => $this->options['limit'] ?? null,
226
296
  'filters' => $this->options['filters'] ?? [],
@@ -229,6 +299,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
229
299
  $enrichedDevices = $enrichmentResponse['devices'] ?? [];
230
300
  $totalEnrichment += count($enrichedDevices);
231
301
 
302
+ $checkpoint('ingesting_enrichment', true);
232
303
  foreach ($enrichedDevices as $devicePayload) {
233
304
  $normalizedDevice = $provider->normalizeDevice($devicePayload);
234
305
  try {
@@ -256,7 +327,10 @@ class SyncTelematicDevicesJob implements ShouldQueue
256
327
  'imei' => $devicePayload['imei'] ?? null,
257
328
  ]);
258
329
  }
330
+ $itemsSinceCheckpoint++;
331
+ $checkpoint('ingesting_enrichment');
259
332
  }
333
+ $checkpoint('ingesting_enrichment', true);
260
334
 
261
335
  Log::info($this->telematic->provider === 'safee' ? 'Safee telemetry enrichment completed' : 'Telematics telemetry enrichment completed', [
262
336
  'correlation_id' => $correlationId,
@@ -289,6 +363,8 @@ class SyncTelematicDevicesJob implements ShouldQueue
289
363
  'last_sync_job_id' => $this->jobId,
290
364
  'last_sync_completed_at' => now()->toDateTimeString(),
291
365
  'last_sync_result' => 'success',
366
+ 'last_sync_phase' => 'completed',
367
+ 'last_sync_progress_at' => now()->toDateTimeString(),
292
368
  'last_sync_total' => $totalLinked,
293
369
  'last_sync_fetched_total' => $totalFetched,
294
370
  'last_sync_linked_total' => $totalLinked,
@@ -310,7 +386,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
310
386
  'last_sync_error_context' => null,
311
387
  ]);
312
388
  $this->telematic->save();
313
- } catch (\Exception $e) {
389
+ } catch (\Throwable $e) {
314
390
  $failureContext = method_exists($e, 'context') ? $e->context() : [];
315
391
  $failureMessage = $this->safeSyncErrorMessage($e);
316
392
 
@@ -37,6 +37,7 @@ class TestTelematicConnectionJob implements ShouldQueue
37
37
  {
38
38
  $this->telematic = $telematic;
39
39
  $this->jobId = $jobId ?? Str::uuid()->toString();
40
+ $this->onQueue(\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::pollQueue());
40
41
  }
41
42
 
42
43
  /**
@@ -149,8 +149,10 @@ class FleetOpsServiceProvider extends CoreServiceProvider
149
149
  $schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb();
150
150
  $schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb();
151
151
  $schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb();
152
- $schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping()->storeOutputInDb();
153
- $schedule->command('fleetops:drain-telematic-inbox')->everyMinute()->withoutOverlapping();
152
+ // These commands dispatch bounded jobs. A scheduler restart must not
153
+ // leave the default 24-hour overlap lease blocking telemetry recovery.
154
+ $schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping(2)->storeOutputInDb();
155
+ $schedule->command('fleetops:drain-telematic-inbox')->everyMinute()->withoutOverlapping(2);
154
156
  });
155
157
  $this->registerNotifications();
156
158
  $this->registerAiCapabilities();
@@ -196,6 +198,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
196
198
  $this->mergeConfigFrom(__DIR__ . '/../../config/fleetops.php', 'fleetops');
197
199
  $this->mergeConfigFrom(__DIR__ . '/../../config/telematics.php', 'telematics');
198
200
  $this->mergeConfigFrom(__DIR__ . '/../../config/afaqy.php', 'telematics.afaqy');
201
+ $this->mergeConfigFrom(__DIR__ . '/../../config/safee.php', 'telematics.safee');
199
202
  $this->mergeConfigFrom(__DIR__ . '/../../config/telemetry.php', 'telematics.telemetry');
200
203
  $this->mergeConfigFrom(__DIR__ . '/../../config/fuel-providers.php', 'fuel-providers');
201
204
  $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'api');
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers;
5
5
  use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
6
6
  use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException;
7
7
  use Fleetbase\FleetOps\Support\Telematics\Afaqy\Payload;
8
+ use Illuminate\Contracts\Encryption\DecryptException;
8
9
  use Illuminate\Http\Client\ConnectionException;
9
10
  use Illuminate\Http\Client\Response;
10
11
  use Illuminate\Support\Facades\Cache;
@@ -20,12 +21,13 @@ use Illuminate\Support\Facades\Log;
20
21
  */
21
22
  class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
22
23
  {
23
- protected string $baseUrl = 'https://api.afaqy.sa';
24
- protected int $requestsPerMinute = 60;
25
- protected int $dataTimeout = 120;
26
- protected int $connectTimeout = 15;
27
- protected int $connectionTestTimeout = 30;
28
- protected int $connectionTestConnectTimeout = 10;
24
+ protected string $baseUrl = 'https://api.afaqy.sa';
25
+ protected int $requestsPerMinute = 60;
26
+ protected int $dataTimeout = 120;
27
+ protected int $connectTimeout = 15;
28
+ protected int $connectionTestTimeout = 30;
29
+ protected int $connectionTestConnectTimeout = 10;
30
+ protected ?float $requestDeadline = null;
29
31
 
30
32
  protected function prepareAuthentication(): void
31
33
  {
@@ -343,9 +345,11 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
343
345
  }
344
346
 
345
347
  $this->reserveRequest();
348
+ $timeout = $this->remainingRequestTimeout(30);
346
349
  $response = Http::asJson()
347
350
  ->acceptJson()
348
- ->timeout(30)
351
+ ->timeout($timeout)
352
+ ->connectTimeout(min($this->connectTimeout, $timeout))
349
353
  ->post($this->baseUrl . '/auth/login', [
350
354
  'data' => [
351
355
  'username' => $this->credentials['username'],
@@ -373,6 +377,20 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
373
377
  }
374
378
 
375
379
  protected function authenticatedPost(string $endpoint, array $payload = [], bool $tokenInQuery = false, bool $allowRetry = true, ?int $timeout = null, ?int $connectTimeout = null): array
380
+ {
381
+ $previousDeadline = $this->requestDeadline;
382
+ $this->requestDeadline ??= $this->requestTime() + max(1, $timeout ?? $this->dataTimeout);
383
+
384
+ try {
385
+ return $this->postWithinDeadline($endpoint, $payload, $tokenInQuery, $allowRetry, $timeout, $connectTimeout);
386
+ } finally {
387
+ // Providers may be reused. A completed or failed request must not
388
+ // leave its deadline on the next independent request.
389
+ $this->requestDeadline = $previousDeadline;
390
+ }
391
+ }
392
+
393
+ protected function postWithinDeadline(string $endpoint, array $payload, bool $tokenInQuery, bool $allowRetry, ?int $timeout, ?int $connectTimeout): array
376
394
  {
377
395
  [$url, $body] = $this->buildAuthenticatedRequest($endpoint, $payload, $tokenInQuery);
378
396
 
@@ -381,6 +399,8 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
381
399
  $connectTimeout ??= $this->connectTimeout;
382
400
 
383
401
  $this->reserveRequest();
402
+ $timeout = $this->remainingRequestTimeout($timeout);
403
+ $connectTimeout = min(max(1, $connectTimeout), $timeout);
384
404
  try {
385
405
  $response = Http::withHeaders($this->headers)
386
406
  ->timeout($timeout)
@@ -468,9 +488,22 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
468
488
  {
469
489
  $key = 'afaqy:token:' . $this->accountKey() . ':' . hash('sha256', $this->credentials['password'] ?? '');
470
490
 
471
- return Cache::lock($key . ':lock', 40)->block(5, function () use ($key, $rejected) {
491
+ return Cache::lock($key . ':lock', 40)->block($this->remainingRequestTimeout(5), function () use ($key, $rejected) {
472
492
  $stored = Cache::get($key);
473
- $token = $stored ? Crypt::decryptString($stored) : null;
493
+ $token = null;
494
+ if ($stored) {
495
+ try {
496
+ $token = Crypt::decryptString($stored);
497
+ } catch (DecryptException) {
498
+ // A cached token is disposable. Evict only this entry while holding
499
+ // the existing account lock, then authenticate with resolved credentials.
500
+ Cache::forget($key);
501
+ Log::warning('AFAQY cached token could not be decrypted; re-authenticating.', [
502
+ 'telematic_uuid' => $this->telematic?->uuid,
503
+ 'reason' => 'cached_token_decryption_failed',
504
+ ]);
505
+ }
506
+ }
474
507
  if (!$token || $token === $rejected) {
475
508
  $token = $this->authenticate();
476
509
  Cache::put($key, Crypt::encryptString($token), 29 * 86400);
@@ -483,7 +516,8 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
483
516
  protected function reserveRequest(): void
484
517
  {
485
518
  $key = 'afaqy:rate:' . $this->accountKey();
486
- Cache::lock($key . ':lock', 5)->block(2, function () use ($key) {
519
+ Cache::lock($key . ':lock', 5)->block($this->remainingRequestTimeout(2), function () use ($key) {
520
+ $this->remainingRequestTimeout(1);
487
521
  $now = microtime(true);
488
522
  $until = (float) Cache::get($key . ':blocked', 0);
489
523
  $requests = array_values(array_filter(Cache::get($key, []), fn ($at) => $at > $now - 60));
@@ -495,6 +529,27 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
495
529
  });
496
530
  }
497
531
 
532
+ /** Monotonic time keeps the request budget independent of wall-clock corrections. */
533
+ protected function requestTime(): float
534
+ {
535
+ return hrtime(true) / 1_000_000_000;
536
+ }
537
+
538
+ protected function remainingRequestTimeout(int $maximum): int
539
+ {
540
+ $maximum = max(1, $maximum);
541
+ if ($this->requestDeadline === null) {
542
+ return $maximum;
543
+ }
544
+
545
+ $remaining = (int) floor($this->requestDeadline - $this->requestTime());
546
+ if ($remaining < 1) {
547
+ throw new TelematicProviderException('AFAQY request time budget exhausted before another operation could start.');
548
+ }
549
+
550
+ return min($maximum, $remaining);
551
+ }
552
+
498
553
  protected function checkThrottle(Response $response): void
499
554
  {
500
555
  if ($response->status() !== 429) {