Skip to content

Commit ddbddf7

Browse files
committed
Add support for cancelling pending Send requests
1 parent 920a5cc commit ddbddf7

File tree

8 files changed

+122
-38
lines changed

8 files changed

+122
-38
lines changed

src/components/send/response-pane.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class ResponsePane extends React.Component<{
2727

2828
requestInput: RequestInput,
2929
exchange: HttpExchange | undefined,
30+
abortRequest: (() => void) | undefined,
3031
editorNode: portals.HtmlPortalNode<typeof ContainerSizedEditor>
3132
}> {
3233

@@ -101,12 +102,19 @@ export class ResponsePane extends React.Component<{
101102
}
102103

103104
renderInProgressResponse() {
104-
const { uiStore, editorNode, requestInput, exchange } = this.props;
105+
const {
106+
uiStore,
107+
editorNode,
108+
requestInput,
109+
exchange,
110+
abortRequest
111+
} = this.props;
105112

106113
return <>
107114
<PendingResponseStatusSection
108115
theme={uiStore!.theme}
109116
timingEvents={exchange?.timingEvents}
117+
abortRequest={abortRequest}
110118
/>
111119
<PendingResponseHeaderSection
112120
{...this.cardProps.responseHeaders}

src/components/send/send-page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ export class SendPage extends React.Component<{
6868
selectRequest,
6969
moveSelection,
7070
deleteRequest,
71-
sendRequest,
7271
selectedRequest,
7372
addRequestInput
7473
} = this.props.sendStore!;
@@ -102,13 +101,16 @@ export class SendPage extends React.Component<{
102101
requestInput={selectedRequest.request}
103102
sendRequest={this.sendRequest}
104103
isSending={
105-
selectedRequest.pendingSendPromise?.state === 'pending'
104+
selectedRequest.pendingSend?.promise.state === 'pending'
106105
}
107106
editorNode={this.requestEditorNode}
108107
/>
109108
<ResponsePane
110109
requestInput={selectedRequest.request}
111110
exchange={selectedRequest.sentExchange}
111+
abortRequest={
112+
selectedRequest.pendingSend?.abort
113+
}
112114
editorNode={this.responseEditorNode}
113115
/>
114116
</SplitPane>

src/components/send/sent-response-error.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export const SentResponseError = (props: {
3131

3232
if (
3333
!wasNotForwarded(errorType) &&
34-
!wasServerIssue(errorType)
34+
!wasServerIssue(errorType) &&
35+
errorType !== 'client-abort'
3536
) {
3637
logError(`Unexpected Send error type: ${errorType}`);
3738
}
@@ -51,6 +52,8 @@ export const SentResponseError = (props: {
5152
? 'This request was not sent successfully'
5253
: wasServerIssue(errorType)
5354
? 'This response was not received successfully'
55+
: errorType === 'client-abort'
56+
? 'This request was cancelled'
5457
: `The request failed because of an unexpected error: ${errorType}`
5558
} <WarningIcon />
5659
</FailureBlock>
@@ -89,14 +92,24 @@ export const SentResponseError = (props: {
8992
: unreachableCheck(errorType)
9093
}.
9194
</ExplanationBlock>
95+
: errorType === 'client-abort'
96+
? <>
97+
<ExplanationBlock>
98+
This request was cancelled after sending, before a response was completed.
99+
</ExplanationBlock>
100+
<ExplanationBlock>
101+
The server may have received and could still be processing this request, but
102+
the connection has been closed so HTTP Toolkit will not receive any response.
103+
</ExplanationBlock>
104+
</>
92105
: <ExplanationBlock>
93106
It's not clear what's gone wrong here, but for some reason HTTP Toolkit
94107
couldn't successfully and/or securely complete this request. This might be an
95108
intermittent issue, and may be resolved by retrying the request.
96109
</ExplanationBlock>
97110
}
98111
{ !!errorMessage &&
99-
<ContentMonoValue>{ errorMessage }</ContentMonoValue>
112+
<ContentMonoValue>Error: { errorMessage }</ContentMonoValue>
100113
}
101114
</SendCardSection>
102115
}

src/components/send/sent-response-status.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import { CompletedExchange, SuccessfulExchange } from '../../model/http/exchange
1212
import { ErrorType } from '../../model/http/error-types';
1313

1414
import { SendCardSection } from './send-card-section';
15-
import { Pill } from '../common/pill';
15+
import { Pill, PillButton } from '../common/pill';
1616
import { DurationPill } from '../common/duration-pill';
17+
import { Icon } from '../../icons';
1718

1819
const ResponseStatusSectionCard = styled(SendCardSection)`
1920
padding-top: 7px;
@@ -56,9 +57,17 @@ export const ResponseStatusSection = (props: {
5657
</ResponseStatusSectionCard>;
5758
}
5859

60+
const AbortButton = styled(PillButton)`
61+
margin-left: auto;
62+
svg {
63+
margin-right: 5px;
64+
}
65+
`;
66+
5967
export const PendingResponseStatusSection = observer((props: {
6068
theme: Theme,
61-
timingEvents?: Partial<TimingEvents>
69+
timingEvents?: Partial<TimingEvents>,
70+
abortRequest?: () => void
6271
}) => {
6372
return <ResponseStatusSectionCard
6473
className='ignores-expanded' // This always shows, even if something is expanded
@@ -73,6 +82,15 @@ export const PendingResponseStatusSection = observer((props: {
7382
&nbsp;...&nbsp;
7483
</Pill>
7584
<DurationPill timingEvents={props.timingEvents ?? {}} />
85+
{ props.abortRequest &&
86+
<AbortButton
87+
color={props.theme.popColor}
88+
onClick={props.abortRequest}
89+
>
90+
<Icon icon={['fas', 'times']} />
91+
Cancel request
92+
</AbortButton>
93+
}
7694
</header>
7795
</ResponseStatusSectionCard>;
7896
});

src/model/send/send-request-model.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ export interface SendRequest {
7676
id: string;
7777
request: RequestInput;
7878
sentExchange: HttpExchange | undefined;
79-
pendingSendPromise?: ObservablePromise<void>;
79+
pendingSend?: {
80+
promise: ObservablePromise<void>,
81+
abort: () => void
82+
}
8083
}
8184

8285
const requestInputSchema = serializr.createModelSchema(RequestInput, {
@@ -102,7 +105,7 @@ export const sendRequestSchema = serializr.createSimpleSchema({
102105
id: serializr.primitive(),
103106
request: serializr.object(requestInputSchema),
104107
sentExchange: false, // Never persisted here (exportable as HAR etc though)
105-
pendingSendPromise: false // Never persisted at all
108+
pendingSend: false // Never persisted at all
106109
});
107110

108111
export async function buildRequestInputFromExchange(exchange: HttpExchange): Promise<RequestInput> {

src/model/send/send-store.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,18 @@ export class SendStore {
132132

133133
const requestInput = sendRequest.request;
134134
const pendingRequestDeferred = getObservableDeferred();
135+
const abortController = new AbortController();
135136
runInAction(() => {
136137
sendRequest.sentExchange = undefined;
137138

138-
sendRequest.pendingSendPromise = pendingRequestDeferred.promise;
139-
sendRequest.pendingSendPromise.then(() => { sendRequest.pendingSendPromise = undefined; });
140-
});
139+
sendRequest.pendingSend = {
140+
promise: pendingRequestDeferred.promise,
141+
abort: () => abortController.abort()
142+
};
141143

144+
const clearPending = action(() => { sendRequest.pendingSend = undefined; });
145+
sendRequest.pendingSend.promise.then(clearPending, clearPending);
146+
});
142147

143148
const exchangeId = uuid();
144149

@@ -163,12 +168,16 @@ export class SendStore {
163168

164169
const encodedBody = await requestInput.rawBody.encodingBestEffortPromise;
165170

166-
const responseStream = await ServerApi.sendRequest({
167-
url: requestInput.url,
168-
method: requestInput.method,
169-
headers: requestInput.headers,
170-
rawBody: encodedBody
171-
}, requestOptions);
171+
const responseStream = await ServerApi.sendRequest(
172+
{
173+
url: requestInput.url,
174+
method: requestInput.method,
175+
headers: requestInput.headers,
176+
rawBody: encodedBody
177+
},
178+
requestOptions,
179+
abortController.signal
180+
);
172181

173182
const exchange = this.eventStore.recordSentRequest({
174183
id: exchangeId,
@@ -190,23 +199,43 @@ export class SendStore {
190199
// Keep the exchange up to date as response data arrives:
191200
trackResponseEvents(responseStream, exchange)
192201
.catch(action((error: ErrorLike & { timingEvents?: TimingEvents }) => {
193-
exchange.markAborted({
194-
id: exchange.id,
195-
error: error,
196-
timingEvents: {
197-
...exchange.timingEvents as TimingEvents,
198-
...error.timingEvents
199-
},
200-
tags: error.code ? [`passthrough-error:${error.code}`] : []
201-
});
202+
if (error.name === 'AbortError' && abortController.signal.aborted) {
203+
const startTime = exchange.timingEvents.startTime!; // Always set in Send case (just above)
204+
// Make a guess at an aborted timestamp, since this error won't give us one automatically:
205+
const durationBeforeAbort = Date.now() - startTime;
206+
const startTimestamp = exchange.timingEvents.startTimestamp ?? startTime;
207+
const abortedTimestamp = startTimestamp + durationBeforeAbort;
208+
209+
exchange.markAborted({
210+
id: exchange.id,
211+
error: {
212+
message: 'Request cancelled'
213+
},
214+
timingEvents: {
215+
startTimestamp,
216+
abortedTimestamp,
217+
...exchange.timingEvents,
218+
...error.timingEvents
219+
} as TimingEvents,
220+
tags: ['client-error:ECONNABORTED']
221+
});
222+
} else {
223+
exchange.markAborted({
224+
id: exchange.id,
225+
error: error,
226+
timingEvents: {
227+
...exchange.timingEvents as TimingEvents,
228+
...error.timingEvents
229+
},
230+
tags: error.code ? [`passthrough-error:${error.code}`] : []
231+
});
232+
}
202233
}))
203234
.then(() => pendingRequestDeferred.resolve());
204235

205236
runInAction(() => {
206237
sendRequest.sentExchange = exchange;
207238
});
208-
209-
return sendRequest.pendingSendPromise;
210239
}
211240

212241
}

src/services/server-api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,15 @@ export async function activateInterceptor(id: string, proxyPort: number, options
113113

114114
export async function sendRequest(
115115
requestDefinition: RequestDefinition,
116-
requestOptions: RequestOptions
116+
requestOptions: RequestOptions,
117+
abortSignal: AbortSignal
117118
) {
118119
const client = (await apiClient);
119120
if (!(client instanceof RestApiClient)) {
120121
throw new Error("Requests cannot be sent via the GraphQL API client");
121122
}
122123

123-
return client.sendRequest(requestDefinition, requestOptions);
124+
return client.sendRequest(requestDefinition, requestOptions, { abortSignal });
124125
}
125126

126127
export async function triggerServerUpdate() {

src/services/server-rest-api.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import {
2222
ResponseStreamEvent
2323
} from '../model/send/send-response-model';
2424

25+
interface RestRequestOptions {
26+
abortSignal?: AbortSignal
27+
}
28+
2529
export class RestApiClient {
2630

2731
constructor(
@@ -32,7 +36,8 @@ export class RestApiClient {
3236
method: string,
3337
path: string,
3438
query: Record<string, number | string> = {},
35-
body?: object
39+
body?: object,
40+
options?: RestRequestOptions
3641
) {
3742
const operationName = `${method} ${path}`;
3843

@@ -50,7 +55,8 @@ export class RestApiClient {
5055
},
5156
body: body
5257
? JSON.stringify(body)
53-
: undefined
58+
: undefined,
59+
signal: options?.abortSignal
5460
}).catch((e) => {
5561
throw new ApiError(`fetch failed with '${e.message ?? e}'`, operationName);
5662
});
@@ -96,9 +102,10 @@ export class RestApiClient {
96102
method: string,
97103
path: string,
98104
query: Record<string, number | string> = {},
99-
body?: object
105+
body?: object,
106+
options?: RestRequestOptions
100107
): Promise<ReadableStream<T>> {
101-
const response = await this.apiRequest(method, path, query, body);
108+
const response = await this.apiRequest(method, path, query, body, options);
102109

103110
if (!response.body) return emptyStream();
104111

@@ -156,7 +163,8 @@ export class RestApiClient {
156163

157164
async sendRequest(
158165
requestDefinition: RequestDefinition,
159-
requestOptions: RequestOptions
166+
requestOptions: RequestOptions,
167+
options?: RestRequestOptions
160168
) {
161169
const requestDefinitionData = {
162170
...requestDefinition,
@@ -174,10 +182,12 @@ export class RestApiClient {
174182
}
175183

176184
const responseDataStream = await this.apiNdJsonRequest<ResponseStreamEventData>(
177-
'POST', '/client/send', {}, {
185+
'POST', '/client/send', {},
186+
{
178187
request: requestDefinitionData,
179188
options: requestOptionsData
180-
}
189+
},
190+
options
181191
);
182192

183193
const dataStreamReader = responseDataStream.getReader();

0 commit comments

Comments
 (0)