Skip to content

Commit 2df2ed6

Browse files
authored
fix(platform-sdk): keep the API's error on a 503 from command operations (ALIEN-1183) (#827)
1 parent 16cce6b commit 2df2ed6

15 files changed

Lines changed: 206 additions & 28 deletions

‎client-sdks/platform/openapi.json‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎client-sdks/platform/rust/openapi-3.0.json‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎client-sdks/platform/rust/openapi.json‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Most of the operations below are outside the filtered client the CLI builds.
2+
#![cfg(feature = "full-api")]
3+
4+
use std::{
5+
io::{Read, Write},
6+
net::TcpListener,
7+
thread,
8+
};
9+
10+
use alien_error::{AlienError, GenericError};
11+
use alien_platform_api::{
12+
types::{CompleteCommandRequestState, UpdateCommandRequestState},
13+
Client, SdkResultExt,
14+
};
15+
use chrono::Utc;
16+
use serde_json::json;
17+
18+
const DEPLOYMENT_ID: &str = "dep_0c29fq4a2yjb7kx3smwdgxlcmpqr";
19+
const COMMAND_ID: &str = "cmd_2sxjXxvOYct7IohT3ukliAzfmpqr";
20+
21+
/// A client whose server answers one request with the API's 503 error and closes.
22+
fn client_of_an_unavailable_api() -> Client {
23+
let listener = TcpListener::bind("127.0.0.1:0").expect("bind a local port");
24+
let base_url = format!("http://{}", listener.local_addr().expect("local address"));
25+
thread::spawn(move || {
26+
let (mut stream, _) = listener.accept().expect("accept the request");
27+
let mut request = [0u8; 4096];
28+
let _ = stream.read(&mut request).expect("read the request");
29+
let body = json!({
30+
"code": "SERVICE_UNAVAILABLE",
31+
"message": "The API cannot serve the request right now.",
32+
"retryable": true,
33+
"internal": false,
34+
"httpStatusCode": 503,
35+
"requestId": "00000000-0000-4000-8000-000000000000"
36+
})
37+
.to_string();
38+
write!(
39+
stream,
40+
"HTTP/1.1 503 Service Unavailable\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
41+
body.len()
42+
)
43+
.expect("write the response");
44+
});
45+
Client::new(&base_url)
46+
}
47+
48+
// A manager keeps its commands in the API through these operations. When the API is briefly
49+
// unable to serve one it answers 503 with its own error; the caller needs that code and its
50+
// retryable flag, not "Unexpected response: 503".
51+
#[tokio::test]
52+
async fn keeps_the_api_error_when_a_command_operation_is_unavailable() {
53+
let failures: [(&str, Option<AlienError<GenericError>>); 6] = [
54+
(
55+
"resolveCommandTarget",
56+
client_of_an_unavailable_api()
57+
.resolve_command_target()
58+
.deployment_id(DEPLOYMENT_ID)
59+
.command("reindex")
60+
.send()
61+
.await
62+
.into_sdk_error()
63+
.err(),
64+
),
65+
(
66+
"getCommand",
67+
client_of_an_unavailable_api()
68+
.get_command()
69+
.id(COMMAND_ID)
70+
.send()
71+
.await
72+
.into_sdk_error()
73+
.err(),
74+
),
75+
(
76+
"updateCommand",
77+
client_of_an_unavailable_api()
78+
.update_command()
79+
.id(COMMAND_ID)
80+
.body_map(|body| body.state(UpdateCommandRequestState::Dispatched))
81+
.send()
82+
.await
83+
.into_sdk_error()
84+
.err(),
85+
),
86+
(
87+
"dispatchCommand",
88+
client_of_an_unavailable_api()
89+
.dispatch_command()
90+
.id(COMMAND_ID)
91+
.body_map(|body| body.dispatched_at(Utc::now()))
92+
.send()
93+
.await
94+
.into_sdk_error()
95+
.err(),
96+
),
97+
(
98+
"completeCommand",
99+
client_of_an_unavailable_api()
100+
.complete_command()
101+
.id(COMMAND_ID)
102+
.body_map(|body| {
103+
body.state(CompleteCommandRequestState::Succeeded)
104+
.completed_at(Utc::now())
105+
})
106+
.send()
107+
.await
108+
.into_sdk_error()
109+
.err(),
110+
),
111+
(
112+
"incrementCommandAttempt",
113+
client_of_an_unavailable_api()
114+
.increment_command_attempt()
115+
.id(COMMAND_ID)
116+
.send()
117+
.await
118+
.into_sdk_error()
119+
.err(),
120+
),
121+
];
122+
123+
for (operation, failure) in failures {
124+
let error = failure.unwrap_or_else(|| panic!("{operation}: a 503 is an error"));
125+
assert_eq!(error.code, "SERVICE_UNAVAILABLE", "{operation}");
126+
assert_eq!(
127+
error.message, "The API cannot serve the request right now.",
128+
"{operation}"
129+
);
130+
assert!(error.retryable, "{operation}");
131+
assert_eq!(error.http_status_code, Some(503), "{operation}");
132+
assert_eq!(
133+
error.context,
134+
Some(json!({ "requestId": "00000000-0000-4000-8000-000000000000" })),
135+
"{operation}"
136+
);
137+
}
138+
}

‎client-sdks/platform/scripts/typescript-sdk.test.mjs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
3+
import { APIError } from "../typescript/esm/models/errors/apierror.js";
34
import { HTTPClient } from "../typescript/esm/lib/http.js";
45
import { Alien } from "../typescript/esm/sdk/sdk.js";
56
import { OperationsPermissionDiff$inboundSchema } from "../typescript/esm/models/index.js";
@@ -271,3 +272,42 @@ test("Events.get still rejects malformed rotation events", async () => {
271272
name: "ResponseValidationError",
272273
});
273274
});
275+
276+
const commandId = "cmd_2sxjXxvOYct7IohT3ukliAzfmpqr";
277+
for (const [operation, request] of [
278+
["resolveTarget", { deploymentId, command: "reindex" }],
279+
["get", { id: commandId }],
280+
["update", { id: commandId, updateCommandRequest: { state: "DISPATCHED" } }],
281+
["dispatch", { id: commandId, dispatchCommandRequest: { dispatchedAt: new Date() } }],
282+
["complete", { id: commandId, completeCommandRequest: { state: "SUCCEEDED", completedAt: new Date() } }],
283+
["incrementAttempt", { id: commandId }],
284+
]) {
285+
test(`Commands.${operation} preserves a structured 503 API error`, async () => {
286+
let requests = 0;
287+
const body = {
288+
code: "SERVICE_UNAVAILABLE",
289+
message: "The API cannot serve the request right now.",
290+
retryable: true,
291+
internal: false,
292+
httpStatusCode: 503,
293+
requestId: "00000000-0000-4000-8000-000000000000",
294+
};
295+
const sdk = client(async () => {
296+
requests++;
297+
return Response.json(body, { status: 503 });
298+
});
299+
await assert.rejects(
300+
sdk.commands[operation](request, { retries: { strategy: "none" } }),
301+
error => {
302+
assert.ok(error instanceof APIError);
303+
assert.equal(error.code, body.code);
304+
assert.equal(error.message, body.message);
305+
assert.equal(error.retryable, body.retryable);
306+
assert.equal(error.httpStatusCode, body.httpStatusCode);
307+
assert.equal(error.requestId, body.requestId);
308+
return true;
309+
},
310+
);
311+
assert.equal(requests, 1);
312+
});
313+
}

‎client-sdks/platform/typescript/docs/models/createcommandrequest.md‎

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)