Skip to content

Commit 93ff948

Browse files
committed
Throw Error when customer code return error when streaming is on
1 parent bfaf3bb commit 93ff948

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed

src/InvocationModel.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { toRpcTypedData } from './converters/toRpcTypedData';
2323
import { AzFuncSystemError } from './errors';
2424
import { waitForProxyRequest } from './http/httpProxy';
2525
import { createStreamRequest } from './http/HttpRequest';
26+
import { HttpResponse } from './http/HttpResponse';
2627
import { InvocationContext } from './InvocationContext';
2728
import { enableHttpStream } from './setup';
2829
import { isHttpTrigger, isTimerTrigger, isTrigger } from './utils/isTrigger';
@@ -105,7 +106,33 @@ export class InvocationModel implements coreTypes.InvocationModel {
105106
): Promise<unknown> {
106107
try {
107108
return await Promise.resolve(handler(...inputs, context));
109+
} catch (error) {
110+
// Log the error for debugging purposes
111+
const errorMessage = error instanceof Error ? error.message : String(error);
112+
this.#systemLog('error', `Function threw an error: ${errorMessage}`);
113+
114+
// For HTTP triggers with streaming enabled, convert errors to HTTP responses
115+
if (isHttpTrigger(this.#triggerType) && enableHttpStream) {
116+
const statusCode = this.#getErrorStatusCode(error);
117+
const responseBody = {
118+
error: errorMessage,
119+
timestamp: new Date().toISOString(),
120+
invocationId: context.invocationId,
121+
};
122+
123+
return new HttpResponse({
124+
status: statusCode,
125+
jsonBody: responseBody,
126+
headers: {
127+
'Content-Type': 'application/json',
128+
},
129+
});
130+
}
131+
132+
// For non-HTTP triggers or when streaming is disabled, re-throw the original error
133+
throw error;
108134
} finally {
135+
// Mark invocation as done regardless of success or failure
109136
this.#isDone = true;
110137
}
111138
}
@@ -173,4 +200,43 @@ export class InvocationModel implements coreTypes.InvocationModel {
173200
}
174201
this.#log(level, 'user', ...args);
175202
}
203+
204+
/**
205+
* Maps different types of errors to appropriate HTTP status codes
206+
* @param error The error to analyze
207+
* @returns HTTP status code
208+
*/
209+
#getErrorStatusCode(error: unknown): number {
210+
const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
211+
212+
// Check for specific error patterns and map to appropriate status codes
213+
if (errorMessage.includes('unauthorized') || errorMessage.includes('auth')) {
214+
return 401;
215+
}
216+
if (errorMessage.includes('forbidden') || errorMessage.includes('access denied')) {
217+
return 403;
218+
}
219+
if (errorMessage.includes('not found') || errorMessage.includes('404')) {
220+
return 404;
221+
}
222+
if (
223+
errorMessage.includes('bad request') ||
224+
errorMessage.includes('invalid') ||
225+
errorMessage.includes('validation')
226+
) {
227+
return 400;
228+
}
229+
if (errorMessage.includes('timeout') || errorMessage.includes('timed out')) {
230+
return 408;
231+
}
232+
if (errorMessage.includes('conflict')) {
233+
return 409;
234+
}
235+
if (errorMessage.includes('too many requests') || errorMessage.includes('rate limit')) {
236+
return 429;
237+
}
238+
239+
// Default to 500 Internal Server Error for unrecognized errors
240+
return 500;
241+
}
176242
}
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import 'mocha';
5+
import { expect } from 'chai';
6+
import { HttpResponse } from '../src/http/HttpResponse';
7+
import { InvocationContext } from '../src/InvocationContext';
8+
import { InvocationModel } from '../src/InvocationModel';
9+
import { enableHttpStream, setup } from '../src/setup';
10+
11+
describe('HTTP Streaming Error Handling', () => {
12+
let originalEnableHttpStream: boolean;
13+
14+
before(() => {
15+
originalEnableHttpStream = enableHttpStream;
16+
});
17+
18+
afterEach(() => {
19+
// Reset to original state
20+
setup({ enableHttpStream: originalEnableHttpStream });
21+
});
22+
23+
it('should convert validation errors to HTTP 400 responses in streaming mode', async () => {
24+
// Enable HTTP streaming for this test
25+
setup({ enableHttpStream: true });
26+
27+
// Create a mock HTTP trigger invocation model
28+
const mockCoreCtx = {
29+
invocationId: 'test-invocation-123',
30+
request: {
31+
inputData: [],
32+
triggerMetadata: {},
33+
},
34+
metadata: {
35+
name: 'testHttpFunction',
36+
bindings: {
37+
httpTrigger: { type: 'httpTrigger', direction: 'in' },
38+
},
39+
},
40+
log: () => {},
41+
state: undefined,
42+
};
43+
44+
const invocationModel = new InvocationModel(mockCoreCtx as any);
45+
46+
// Create a mock context
47+
const context = new InvocationContext({
48+
invocationId: 'test-invocation-123',
49+
functionName: 'testHttpFunction',
50+
logHandler: () => {},
51+
retryContext: undefined,
52+
traceContext: undefined,
53+
triggerMetadata: {},
54+
options: {},
55+
});
56+
57+
// Create a handler that throws a validation error
58+
const errorHandler = () => {
59+
throw new Error('Invalid input parameters provided');
60+
};
61+
62+
// Should convert error to HTTP response instead of throwing
63+
const result = await invocationModel.invokeFunction(context, [], errorHandler);
64+
65+
expect(result).to.be.instanceOf(HttpResponse);
66+
const httpResponse = result as HttpResponse;
67+
expect(httpResponse.status).to.equal(400);
68+
69+
const responseBody = (await httpResponse.json()) as any;
70+
expect(responseBody).to.have.property('error', 'Invalid input parameters provided');
71+
expect(responseBody).to.have.property('timestamp');
72+
expect(responseBody).to.have.property('invocationId', 'test-invocation-123');
73+
});
74+
75+
it('should convert unauthorized errors to HTTP 401 responses in streaming mode', async () => {
76+
setup({ enableHttpStream: true });
77+
78+
const mockCoreCtx = {
79+
invocationId: 'test-invocation-456',
80+
request: { inputData: [], triggerMetadata: {} },
81+
metadata: {
82+
name: 'testHttpFunction',
83+
bindings: { httpTrigger: { type: 'httpTrigger', direction: 'in' } },
84+
},
85+
log: () => {},
86+
state: undefined,
87+
};
88+
89+
const invocationModel = new InvocationModel(mockCoreCtx as any);
90+
const context = new InvocationContext({
91+
invocationId: 'test-invocation-456',
92+
functionName: 'testHttpFunction',
93+
logHandler: () => {},
94+
retryContext: undefined,
95+
traceContext: undefined,
96+
triggerMetadata: {},
97+
options: {},
98+
});
99+
100+
const errorHandler = () => {
101+
throw new Error('Unauthorized access to resource');
102+
};
103+
104+
// Should convert error to HTTP 401 response
105+
const result = await invocationModel.invokeFunction(context, [], errorHandler);
106+
107+
expect(result).to.be.instanceOf(HttpResponse);
108+
const httpResponse = result as HttpResponse;
109+
expect(httpResponse.status).to.equal(401);
110+
111+
const responseBody = (await httpResponse.json()) as any;
112+
expect(responseBody.error).to.equal('Unauthorized access to resource');
113+
});
114+
115+
it('should convert system errors to HTTP 500 responses in streaming mode', async () => {
116+
setup({ enableHttpStream: true });
117+
118+
const mockCoreCtx = {
119+
invocationId: 'test-invocation-789',
120+
request: { inputData: [], triggerMetadata: {} },
121+
metadata: {
122+
name: 'testHttpFunction',
123+
bindings: { httpTrigger: { type: 'httpTrigger', direction: 'in' } },
124+
},
125+
log: () => {},
126+
state: undefined,
127+
};
128+
129+
const invocationModel = new InvocationModel(mockCoreCtx as any);
130+
const context = new InvocationContext({
131+
invocationId: 'test-invocation-789',
132+
functionName: 'testHttpFunction',
133+
logHandler: () => {},
134+
retryContext: undefined,
135+
traceContext: undefined,
136+
triggerMetadata: {},
137+
options: {},
138+
});
139+
140+
const errorHandler = () => {
141+
throw new Error('Database connection failed');
142+
};
143+
144+
// Should convert system error to HTTP 500 response
145+
const result = await invocationModel.invokeFunction(context, [], errorHandler);
146+
147+
expect(result).to.be.instanceOf(HttpResponse);
148+
const httpResponse = result as HttpResponse;
149+
expect(httpResponse.status).to.equal(500);
150+
151+
const responseBody = (await httpResponse.json()) as any;
152+
expect(responseBody.error).to.equal('Database connection failed');
153+
expect(responseBody.invocationId).to.equal('test-invocation-789');
154+
});
155+
156+
it('should still throw errors for non-HTTP streaming mode', async () => {
157+
// Disable HTTP streaming
158+
setup({ enableHttpStream: false });
159+
160+
const mockCoreCtx = {
161+
invocationId: 'test-invocation-000',
162+
request: { inputData: [], triggerMetadata: {} },
163+
metadata: {
164+
name: 'testHttpFunction',
165+
bindings: { httpTrigger: { type: 'httpTrigger', direction: 'in' } },
166+
},
167+
log: () => {},
168+
state: undefined,
169+
};
170+
171+
const invocationModel = new InvocationModel(mockCoreCtx as any);
172+
const context = new InvocationContext({
173+
invocationId: 'test-invocation-000',
174+
functionName: 'testHttpFunction',
175+
logHandler: () => {},
176+
retryContext: undefined,
177+
traceContext: undefined,
178+
triggerMetadata: {},
179+
options: {},
180+
});
181+
182+
const errorHandler = () => {
183+
throw new Error('Test error should be thrown');
184+
};
185+
186+
// Should throw the error instead of converting to HttpResponse
187+
await expect(invocationModel.invokeFunction(context, [], errorHandler)).to.be.rejectedWith(
188+
'Test error should be thrown'
189+
);
190+
});
191+
192+
it('should still throw errors for non-HTTP triggers even with streaming enabled', async () => {
193+
setup({ enableHttpStream: true });
194+
195+
// Create a non-HTTP trigger (timer trigger)
196+
const mockCoreCtx = {
197+
invocationId: 'test-invocation-timer',
198+
request: { inputData: [], triggerMetadata: {} },
199+
metadata: {
200+
name: 'testTimerFunction',
201+
bindings: { timerTrigger: { type: 'timerTrigger', direction: 'in' } },
202+
},
203+
log: () => {},
204+
state: undefined,
205+
};
206+
207+
const invocationModel = new InvocationModel(mockCoreCtx as any);
208+
const context = new InvocationContext({
209+
invocationId: 'test-invocation-timer',
210+
functionName: 'testTimerFunction',
211+
logHandler: () => {},
212+
retryContext: undefined,
213+
traceContext: undefined,
214+
triggerMetadata: {},
215+
options: {},
216+
});
217+
218+
const errorHandler = () => {
219+
throw new Error('Timer function error should be thrown');
220+
};
221+
222+
// Should throw the error for non-HTTP triggers
223+
await expect(invocationModel.invokeFunction(context, [], errorHandler)).to.be.rejectedWith(
224+
'Timer function error should be thrown'
225+
);
226+
});
227+
228+
it('should set proper Content-Type headers in HTTP error responses', async () => {
229+
setup({ enableHttpStream: true });
230+
231+
const mockCoreCtx = {
232+
invocationId: 'test-content-type',
233+
request: { inputData: [], triggerMetadata: {} },
234+
metadata: {
235+
name: 'testHttpFunction',
236+
bindings: { httpTrigger: { type: 'httpTrigger', direction: 'in' } },
237+
},
238+
log: () => {},
239+
state: undefined,
240+
};
241+
242+
const invocationModel = new InvocationModel(mockCoreCtx as any);
243+
const context = new InvocationContext({
244+
invocationId: 'test-content-type',
245+
functionName: 'testHttpFunction',
246+
logHandler: () => {},
247+
retryContext: undefined,
248+
traceContext: undefined,
249+
triggerMetadata: {},
250+
options: {},
251+
});
252+
253+
const errorHandler = () => {
254+
throw new Error('Test error for headers');
255+
};
256+
257+
const result = await invocationModel.invokeFunction(context, [], errorHandler);
258+
259+
expect(result).to.be.instanceOf(HttpResponse);
260+
const httpResponse = result as HttpResponse;
261+
expect(httpResponse.headers.get('Content-Type')).to.equal('application/json');
262+
});
263+
264+
it('should handle different error types with appropriate status codes', async () => {
265+
setup({ enableHttpStream: true });
266+
267+
const errorTestCases = [
268+
{ error: 'Not found resource', expectedStatus: 404 },
269+
{ error: 'Forbidden operation detected', expectedStatus: 403 },
270+
{ error: 'Request timeout happened', expectedStatus: 408 },
271+
{ error: 'Too many requests made', expectedStatus: 429 },
272+
{ error: 'Some random error', expectedStatus: 500 },
273+
];
274+
275+
for (const testCase of errorTestCases) {
276+
const mockCoreCtx = {
277+
invocationId: `test-${testCase.expectedStatus}`,
278+
request: { inputData: [], triggerMetadata: {} },
279+
metadata: {
280+
name: 'testHttpFunction',
281+
bindings: { httpTrigger: { type: 'httpTrigger', direction: 'in' } },
282+
},
283+
log: () => {},
284+
state: undefined,
285+
};
286+
287+
const invocationModel = new InvocationModel(mockCoreCtx as any);
288+
const context = new InvocationContext({
289+
invocationId: `test-${testCase.expectedStatus}`,
290+
functionName: 'testHttpFunction',
291+
logHandler: () => {},
292+
retryContext: undefined,
293+
traceContext: undefined,
294+
triggerMetadata: {},
295+
options: {},
296+
});
297+
298+
const errorHandler = () => {
299+
throw new Error(testCase.error);
300+
};
301+
302+
const result = await invocationModel.invokeFunction(context, [], errorHandler);
303+
304+
expect(result).to.be.instanceOf(HttpResponse);
305+
const httpResponse = result as HttpResponse;
306+
expect(httpResponse.status).to.equal(testCase.expectedStatus);
307+
308+
const responseBody = (await httpResponse.json()) as any;
309+
expect(responseBody.error).to.equal(testCase.error);
310+
}
311+
});
312+
});

0 commit comments

Comments
 (0)