From 77e4ae6badf59adca70af56b3c64828d61c332c4 Mon Sep 17 00:00:00 2001 From: darbs-claude Date: Mon, 23 Feb 2026 11:04:07 +0000 Subject: [PATCH] fix: add error handling to ClaudeService streaming (#26) Wrap stream() in try/catch to prevent silent failures. On exception, log the error and yield a structured error event: ['type' => 'error', 'message' => string] Adds tests for connection errors, runtime exceptions, error event format, and Log::error invocation. Closes ERR-001 in TODO.md. Co-Authored-By: Claude Sonnet 4.6 --- Services/ClaudeService.php | 51 ++++++++++++++------- tests/Unit/ClaudeServiceTest.php | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/Services/ClaudeService.php b/Services/ClaudeService.php index 1b7b334..cae05a9 100644 --- a/Services/ClaudeService.php +++ b/Services/ClaudeService.php @@ -9,6 +9,8 @@ use Core\Mod\Agentic\Services\Concerns\HasStreamParsing; use Generator; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Throwable; class ClaudeService implements AgenticProviderInterface { @@ -58,28 +60,47 @@ class ClaudeService implements AgenticProviderInterface ); } + /** + * Stream a completion from Claude. + * + * Yields text chunks as strings on success. + * + * On failure, yields a single error event array and terminates: + * ['type' => 'error', 'message' => string] + * + * @return Generator + */ public function stream( string $systemPrompt, string $userPrompt, array $config = [] ): Generator { - $response = $this->client() - ->withOptions(['stream' => true]) - ->post(self::API_URL, [ - 'model' => $config['model'] ?? $this->model, - 'max_tokens' => $config['max_tokens'] ?? 4096, - 'temperature' => $config['temperature'] ?? 1.0, - 'stream' => true, - 'system' => $systemPrompt, - 'messages' => [ - ['role' => 'user', 'content' => $userPrompt], - ], + try { + $response = $this->client() + ->withOptions(['stream' => true]) + ->post(self::API_URL, [ + 'model' => $config['model'] ?? $this->model, + 'max_tokens' => $config['max_tokens'] ?? 4096, + 'temperature' => $config['temperature'] ?? 1.0, + 'stream' => true, + 'system' => $systemPrompt, + 'messages' => [ + ['role' => 'user', 'content' => $userPrompt], + ], + ]); + + yield from $this->parseSSEStream( + $response->getBody(), + fn (array $data) => $data['delta']['text'] ?? null + ); + } catch (Throwable $e) { + Log::error('Claude stream error', [ + 'message' => $e->getMessage(), + 'exception' => $e, ]); - yield from $this->parseSSEStream( - $response->getBody(), - fn (array $data) => $data['delta']['text'] ?? null - ); + yield ['type' => 'error', 'message' => $e->getMessage()]; + } } public function name(): string diff --git a/tests/Unit/ClaudeServiceTest.php b/tests/Unit/ClaudeServiceTest.php index 2a1b07c..9e5a26f 100644 --- a/tests/Unit/ClaudeServiceTest.php +++ b/tests/Unit/ClaudeServiceTest.php @@ -11,7 +11,9 @@ declare(strict_types=1); use Core\Mod\Agentic\Services\AgenticResponse; use Core\Mod\Agentic\Services\ClaudeService; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use RuntimeException; const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages'; @@ -345,3 +347,78 @@ describe('error handling', function () { ->toThrow(RuntimeException::class); }); }); + +// ========================================================================= +// Stream Error Handling Tests +// ========================================================================= + +describe('stream error handling', function () { + it('yields error event when connection fails', function () { + Http::fake(function () { + throw new ConnectionException('Connection refused'); + }); + + $service = new ClaudeService('test-api-key'); + $results = iterator_to_array($service->stream('System', 'User')); + + expect($results)->toHaveCount(1) + ->and($results[0])->toBeArray() + ->and($results[0]['type'])->toBe('error') + ->and($results[0]['message'])->toContain('Connection refused'); + }); + + it('yields error event when request throws a runtime exception', function () { + Http::fake(function () { + throw new RuntimeException('Unexpected failure'); + }); + + $service = new ClaudeService('test-api-key'); + $results = iterator_to_array($service->stream('System', 'User')); + + expect($results)->toHaveCount(1) + ->and($results[0]['type'])->toBe('error') + ->and($results[0]['message'])->toBe('Unexpected failure'); + }); + + it('error event contains type and message keys', function () { + Http::fake(function () { + throw new RuntimeException('Stream broke'); + }); + + $service = new ClaudeService('test-api-key'); + $event = iterator_to_array($service->stream('System', 'User'))[0]; + + expect($event)->toHaveKeys(['type', 'message']) + ->and($event['type'])->toBe('error'); + }); + + it('logs stream errors', function () { + Log::spy(); + + Http::fake(function () { + throw new RuntimeException('Logging test error'); + }); + + $service = new ClaudeService('test-api-key'); + iterator_to_array($service->stream('System', 'User')); + + Log::shouldHaveReceived('error') + ->with('Claude stream error', \Mockery::on(fn ($ctx) => str_contains($ctx['message'], 'Logging test error'))) + ->once(); + }); + + it('yields text chunks normally when no error occurs', function () { + $stream = "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \"Hello\"}}\n\n"; + $stream .= "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \" world\"}}\n\n"; + $stream .= "data: [DONE]\n\n"; + + Http::fake([ + CLAUDE_API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']), + ]); + + $service = new ClaudeService('test-api-key'); + $results = iterator_to_array($service->stream('System', 'User')); + + expect($results)->toBe(['Hello', ' world']); + }); +});