Skip to content

Commit 0bce3c6

Browse files
committed
Add Java request lifecycle hooks and retry proof
1 parent 1281152 commit 0bce3c6

7 files changed

Lines changed: 553 additions & 29 deletions

File tree

example/build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ tasks.register('api2DevdockTusResumeUpload', JavaExec) {
1717
mainClass = 'io.tus.java.example.Api2DevdockTusResumeUpload'
1818
workingDir = rootProject.projectDir
1919
}
20+
21+
tasks.register('api2DevdockTusRetryOffsetRecovery', JavaExec) {
22+
classpath = sourceSets.main.runtimeClasspath
23+
mainClass = 'io.tus.java.example.Api2DevdockTusRetryOffsetRecovery'
24+
workingDir = rootProject.projectDir
25+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package io.tus.java.example;
2+
3+
import io.tus.java.client.ProtocolException;
4+
import io.tus.java.client.TusClient;
5+
import io.tus.java.client.TusExecutor;
6+
import io.tus.java.client.TusRequestLifecycleHooks;
7+
import io.tus.java.client.TusURLMemoryStore;
8+
import io.tus.java.client.TusUpload;
9+
import io.tus.java.client.TusUploader;
10+
import org.json.JSONArray;
11+
import org.json.JSONObject;
12+
13+
import java.io.ByteArrayInputStream;
14+
import java.io.IOException;
15+
import java.net.HttpURLConnection;
16+
import java.net.URL;
17+
import java.util.ArrayList;
18+
import java.util.List;
19+
20+
public final class Api2DevdockTusRetryOffsetRecovery {
21+
/**
22+
* Run the API2 devdock TUS retry-offset recovery example.
23+
*
24+
* @param args ignored
25+
*/
26+
public static void main(String[] args) {
27+
try {
28+
System.setProperty("http.strictPostRedirect", "true");
29+
30+
final JSONObject scenario = Api2DevdockScenario.loadScenario();
31+
final JSONObject createResponse = Api2DevdockScenario.createResponse(scenario);
32+
final JSONObject result = uploadWithRetryOffsetRecovery(scenario, createResponse);
33+
Api2DevdockScenario.writeResult(result);
34+
35+
System.out.println(
36+
"Java TUS SDK devdock scenario "
37+
+ scenario.getString("scenarioId")
38+
+ " recovered offset for "
39+
+ result.getString("uploadUrl")
40+
);
41+
} catch (Exception e) {
42+
e.printStackTrace();
43+
System.exit(1);
44+
}
45+
}
46+
47+
private static JSONObject uploadWithRetryOffsetRecovery(
48+
JSONObject scenario,
49+
JSONObject createResponse
50+
) throws IOException, ProtocolException {
51+
final JSONObject uploadConfig = scenario.getJSONObject("upload");
52+
final JSONObject retry = uploadConfig.getJSONObject("retryOffsetRecovery");
53+
final JSONObject failAfterResponse = retry.getJSONObject("failAfterResponse");
54+
final JSONObject recoveryResponse = retry.getJSONObject("recoveryResponse");
55+
final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig);
56+
final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig);
57+
final List<Integer> recoveredOffsets = new ArrayList<Integer>();
58+
final List<String> requestMethods = new ArrayList<String>();
59+
final int[] failureCandidateCount = new int[]{0};
60+
final int[] simulatedFailureCount = new int[]{0};
61+
62+
final TusClient client = new TusClient();
63+
client.setUploadCreationURL(
64+
new URL(Api2DevdockScenario.tusUrl(uploadConfig, scenario, createResponse))
65+
);
66+
client.enableResuming(new TusURLMemoryStore());
67+
client.setRequestLifecycleHooks(new TusRequestLifecycleHooks(
68+
new TusRequestLifecycleHooks.BeforeRequest() {
69+
@Override
70+
public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) {
71+
requestMethods.add(context.getMethod());
72+
}
73+
},
74+
new TusRequestLifecycleHooks.AfterResponse() {
75+
@Override
76+
public void afterResponse(
77+
TusRequestLifecycleHooks.RequestContext context
78+
) throws IOException {
79+
if (context.getMethod().equals(recoveryResponse.getString("method"))) {
80+
recoveredOffsets.add(readHeaderInt(
81+
context.getConnection(),
82+
recoveryResponse.getString("offsetHeader")
83+
));
84+
}
85+
86+
if (!context.getMethod().equals(failAfterResponse.getString("method"))) {
87+
return;
88+
}
89+
90+
failureCandidateCount[0] += 1;
91+
if (failureCandidateCount[0] != failAfterResponse.getInt("occurrence")) {
92+
return;
93+
}
94+
95+
simulatedFailureCount[0] += 1;
96+
throw new IOException(failAfterResponse.getString("message"));
97+
}
98+
}
99+
));
100+
101+
final TusUpload upload = uploadFor(scenario, createResponse, content);
102+
final String[] uploadUrl = new String[]{null};
103+
final long[] finalOffset = new long[]{0};
104+
final TusExecutor executor = new TusExecutor() {
105+
@Override
106+
protected void makeAttempt() throws ProtocolException, IOException {
107+
final TusUploader uploader = client.resumeOrCreateUpload(upload);
108+
uploader.setChunkSize(chunkSize);
109+
uploader.setRequestPayloadSize(chunkSize);
110+
int uploadedChunkSize;
111+
do {
112+
uploadedChunkSize = uploader.uploadChunk();
113+
} while (uploadedChunkSize > -1);
114+
uploader.finish();
115+
uploadUrl[0] = uploader.getUploadURL().toString();
116+
finalOffset[0] = uploader.getOffset();
117+
}
118+
};
119+
executor.setDelays(new int[uploadConfig.getInt("retries")]);
120+
if (!executor.makeAttempts()) {
121+
throw new IOException("retry offset recovery was interrupted");
122+
}
123+
124+
if (uploadUrl[0] == null) {
125+
throw new IllegalStateException("retry offset recovery TUS upload did not expose a URL");
126+
}
127+
if (finalOffset[0] != content.length) {
128+
throw new IllegalStateException(
129+
"retry offset recovery upload offset "
130+
+ finalOffset[0]
131+
+ ", expected "
132+
+ content.length
133+
);
134+
}
135+
if (simulatedFailureCount[0] != retry.getInt("expectedFailureCount")) {
136+
throw new IllegalStateException(
137+
"retry offset recovery expected "
138+
+ retry.getInt("expectedFailureCount")
139+
+ " simulated failure(s), got "
140+
+ simulatedFailureCount[0]
141+
);
142+
}
143+
if (recoveredOffsets.size() != retry.getInt("expectedRecoveryRequestCount")) {
144+
throw new IllegalStateException(
145+
"retry offset recovery expected "
146+
+ retry.getInt("expectedRecoveryRequestCount")
147+
+ " recovery request(s), got "
148+
+ recoveredOffsets.size()
149+
);
150+
}
151+
if (recoveredOffsets.get(0) != retry.getInt("expectedRecoveredOffset")) {
152+
throw new IllegalStateException(
153+
"retry offset recovery expected recovered offset "
154+
+ retry.getInt("expectedRecoveredOffset")
155+
+ ", got "
156+
+ recoveredOffsets.get(0)
157+
);
158+
}
159+
assertRequestMethods(requestMethods, retry.getJSONArray("expectedRequestMethods"));
160+
161+
return new JSONObject()
162+
.put("recoveredOffsets", new JSONArray(recoveredOffsets))
163+
.put("recoveryRequestCount", recoveredOffsets.size())
164+
.put("requestMethods", new JSONArray(requestMethods))
165+
.put("simulatedFailureCount", simulatedFailureCount[0])
166+
.put("uploadUrl", uploadUrl[0]);
167+
}
168+
169+
private static TusUpload uploadFor(
170+
JSONObject scenario,
171+
JSONObject createResponse,
172+
byte[] content
173+
) {
174+
final JSONObject uploadConfig = scenario.getJSONObject("upload");
175+
final TusUpload upload = new TusUpload();
176+
upload.setInputStream(new ByteArrayInputStream(content));
177+
upload.setSize(content.length);
178+
upload.setFingerprint(scenario.getString("scenarioId") + "-java-retry-offset-recovery");
179+
upload.setMetadata(
180+
Api2DevdockScenario.uploadMetadata(uploadConfig, scenario, createResponse)
181+
);
182+
return upload;
183+
}
184+
185+
private static int readHeaderInt(HttpURLConnection connection, String headerName) {
186+
final String value = connection.getHeaderField(headerName);
187+
final int offset;
188+
try {
189+
offset = Integer.parseInt(value);
190+
} catch (NumberFormatException e) {
191+
throw new IllegalStateException(
192+
"retry offset recovery expected numeric "
193+
+ headerName
194+
+ " response header, got "
195+
+ value
196+
);
197+
}
198+
if (offset < 0) {
199+
throw new IllegalStateException(
200+
"retry offset recovery expected non-negative offset, got " + offset
201+
);
202+
}
203+
204+
return offset;
205+
}
206+
207+
private static void assertRequestMethods(List<String> actual, JSONArray expected) {
208+
if (actual.size() != expected.length()) {
209+
throw new IllegalStateException(
210+
"retry offset recovery expected request methods "
211+
+ expected
212+
+ ", got "
213+
+ actual
214+
);
215+
}
216+
217+
for (int index = 0; index < expected.length(); index++) {
218+
if (!actual.get(index).equals(expected.getString(index))) {
219+
throw new IllegalStateException(
220+
"retry offset recovery expected request method "
221+
+ expected.getString(index)
222+
+ " at index "
223+
+ index
224+
+ ", got "
225+
+ actual.get(index)
226+
);
227+
}
228+
}
229+
}
230+
231+
private Api2DevdockTusRetryOffsetRecovery() {
232+
throw new IllegalStateException("Utility class");
233+
}
234+
}

src/main/java/io/tus/java/client/TusClient.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class TusClient {
2626
private TusURLStore urlStore;
2727
private Map<String, String> headers;
2828
private int connectTimeout = 5000;
29+
private TusRequestLifecycleHooks requestLifecycleHooks;
2930

3031
/**
3132
* Create a new tus client.
@@ -164,6 +165,25 @@ public Map<String, String> getHeaders() {
164165
return headers;
165166
}
166167

168+
/**
169+
* Set request lifecycle callbacks for every HTTP request/response pair.
170+
*
171+
* @param requestLifecycleHooks Hooks to invoke, or null to disable hooks.
172+
*/
173+
public void setRequestLifecycleHooks(@Nullable TusRequestLifecycleHooks requestLifecycleHooks) {
174+
this.requestLifecycleHooks = requestLifecycleHooks;
175+
}
176+
177+
/**
178+
* Get the configured request lifecycle callbacks.
179+
*
180+
* @return The configured lifecycle hooks or null.
181+
*/
182+
@Nullable
183+
public TusRequestLifecycleHooks getRequestLifecycleHooks() {
184+
return requestLifecycleHooks;
185+
}
186+
167187
/**
168188
* Sets the timeout for a Connection.
169189
* @param timeout in milliseconds
@@ -208,9 +228,11 @@ public TusUploader createUpload(@NotNull TusUpload upload) throws ProtocolExcept
208228
} else {
209229
connection.addRequestProperty("Upload-Length", Long.toString(upload.getSize()));
210230
}
231+
runBeforeRequest("POST", connection);
211232
connection.connect();
212233

213234
int responseCode = connection.getResponseCode();
235+
runAfterResponse("POST", connection);
214236
if (!(responseCode >= 200 && responseCode < 300)) {
215237
throw new ProtocolException(
216238
"unexpected status code (" + responseCode + ") while creating upload", connection);
@@ -303,9 +325,11 @@ public TusUploader beginOrResumeUploadFromURL(@NotNull TusUpload upload, @NotNul
303325
connection.setRequestMethod("HEAD");
304326
prepareConnection(connection);
305327

328+
runBeforeRequest("HEAD", connection);
306329
connection.connect();
307330

308331
int responseCode = connection.getResponseCode();
332+
runAfterResponse("HEAD", connection);
309333
if (!(responseCode >= 200 && responseCode < 300)) {
310334
throw new ProtocolException(
311335
"unexpected status code (" + responseCode + ") while resuming upload", connection);
@@ -378,6 +402,26 @@ public void prepareConnection(@NotNull HttpURLConnection connection) {
378402
}
379403
}
380404

405+
void runBeforeRequest(@NotNull String method, @NotNull HttpURLConnection connection) throws IOException {
406+
if (requestLifecycleHooks == null || requestLifecycleHooks.getBeforeRequest() == null) {
407+
return;
408+
}
409+
410+
requestLifecycleHooks.getBeforeRequest().beforeRequest(
411+
new TusRequestLifecycleHooks.RequestContext(method, connection)
412+
);
413+
}
414+
415+
void runAfterResponse(@NotNull String method, @NotNull HttpURLConnection connection) throws IOException {
416+
if (requestLifecycleHooks == null || requestLifecycleHooks.getAfterResponse() == null) {
417+
return;
418+
}
419+
420+
requestLifecycleHooks.getAfterResponse().afterResponse(
421+
new TusRequestLifecycleHooks.RequestContext(method, connection)
422+
);
423+
}
424+
381425
/**
382426
* Actions to be performed after a successful upload completion.
383427
* Manages URL removal from the URL store if remove fingerprint on success is enabled
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.tus.java.client;
2+
3+
import java.io.IOException;
4+
import java.net.HttpURLConnection;
5+
6+
/**
7+
* Callbacks invoked around each HTTP request/response pair.
8+
*/
9+
public final class TusRequestLifecycleHooks {
10+
/**
11+
* Request context passed to lifecycle hooks.
12+
*/
13+
public static final class RequestContext {
14+
private final String method;
15+
private final HttpURLConnection connection;
16+
17+
RequestContext(String method, HttpURLConnection connection) {
18+
this.method = method;
19+
this.connection = connection;
20+
}
21+
22+
public String getMethod() {
23+
return method;
24+
}
25+
26+
public HttpURLConnection getConnection() {
27+
return connection;
28+
}
29+
}
30+
31+
/**
32+
* Callback invoked before transport sends the request.
33+
*/
34+
public interface BeforeRequest {
35+
void beforeRequest(RequestContext context) throws IOException;
36+
}
37+
38+
/**
39+
* Callback invoked after transport receives the response.
40+
*/
41+
public interface AfterResponse {
42+
void afterResponse(RequestContext context) throws IOException;
43+
}
44+
45+
private final BeforeRequest beforeRequest;
46+
private final AfterResponse afterResponse;
47+
48+
public TusRequestLifecycleHooks(BeforeRequest beforeRequest, AfterResponse afterResponse) {
49+
this.beforeRequest = beforeRequest;
50+
this.afterResponse = afterResponse;
51+
}
52+
53+
BeforeRequest getBeforeRequest() {
54+
return beforeRequest;
55+
}
56+
57+
AfterResponse getAfterResponse() {
58+
return afterResponse;
59+
}
60+
}

0 commit comments

Comments
 (0)