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 <noreply@anthropic.com>
This commit is contained in:
parent
fcdeace290
commit
77e4ae6bad
2 changed files with 113 additions and 15 deletions
|
|
@ -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<string|array{type: 'error', message: string}>
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue