Skip to content

Commit 9a50f29

Browse files
oschwaldclaude
andcommitted
feat: Add request_duration measurement for proxy detection
Measure HTTP round-trip time of the first (IPv6) request and send it with the second (IPv4) request. This matches the browser device.js behavior and helps with proxy detection. Changes: - Add requestDuration field to DeviceData (Float?, milliseconds) - Measure time in sendWithDualRequest() around IPv6 request - Pass duration to IPv4 request via DeviceData.copy() - Add tests for dual-request behavior and request_duration field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent d1fa4dd commit 9a50f29

File tree

3 files changed

+129
-3
lines changed

3 files changed

+129
-3
lines changed

device-sdk/src/main/java/com/maxmind/device/model/DeviceData.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,7 @@ public data class DeviceData(
4444
val deviceTime: Long = System.currentTimeMillis(),
4545
@SerialName("webview_user_agent")
4646
val webViewUserAgent: String? = null,
47+
// Request timing for proxy detection (set by DeviceApiClient on IPv4 request)
48+
@SerialName("request_duration")
49+
val requestDuration: Float? = null,
4750
)

device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,28 @@ internal class DeviceApiClient(
8888
* Sends device data using the dual-request flow (IPv6 first, then IPv4 if needed).
8989
*/
9090
private suspend fun sendWithDualRequest(deviceData: DeviceData): Result<ServerResponse> {
91-
// First, try IPv6
91+
// First, try IPv6 - measure duration for proxy detection
9292
val ipv6Url = "https://${SdkConfig.DEFAULT_IPV6_HOST}${SdkConfig.ENDPOINT_PATH}"
93+
val startTime = System.currentTimeMillis()
9394
val ipv6Result = sendToUrl(deviceData, ipv6Url)
95+
val requestDurationMs = System.currentTimeMillis() - startTime
9496

9597
if (ipv6Result.isFailure) {
9698
return ipv6Result
9799
}
98100

99101
val ipv6Response = ipv6Result.getOrNull()!!
100102

101-
// If we got an IPv6 response, also send to IPv4 to capture that IP
103+
// If we got an IPv6 response, also send to IPv4 with the request duration
102104
if (ipv6Response.ipVersion == IPV6) {
103105
val ipv4Url = "https://${SdkConfig.DEFAULT_IPV4_HOST}${SdkConfig.ENDPOINT_PATH}"
106+
val dataWithDuration =
107+
deviceData.copy(
108+
requestDuration = requestDurationMs.toFloat(),
109+
)
104110
// Send to IPv4 but don't fail the overall operation if it fails
105111
// The stored_id from IPv6 is already valid
106-
sendToUrl(deviceData, ipv4Url)
112+
sendToUrl(dataWithDuration, ipv4Url)
107113
}
108114

109115
// Return the IPv6 response (which has the stored_id)

device-sdk/src/test/java/com/maxmind/device/network/DeviceApiClientTest.kt

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import io.ktor.http.headersOf
1919
import io.ktor.serialization.kotlinx.json.json
2020
import kotlinx.coroutines.test.runTest
2121
import kotlinx.serialization.json.Json
22+
import kotlinx.serialization.json.float
2223
import kotlinx.serialization.json.jsonObject
2324
import kotlinx.serialization.json.jsonPrimitive
2425
import org.junit.jupiter.api.Assertions.assertEquals
@@ -328,6 +329,99 @@ internal class DeviceApiClientTest {
328329
client.close()
329330
}
330331

332+
// ========== Dual Request / Request Duration Tests ==========
333+
334+
@Test
335+
internal fun `dual request includes request_duration only on second request`() =
336+
runTest {
337+
val capturedRequests = mutableListOf<Pair<String, String>>()
338+
var requestCount = 0
339+
340+
val mockEngine =
341+
MockEngine { request ->
342+
requestCount++
343+
capturedRequests.add(request.url.toString() to (request.body as TextContent).text)
344+
respond(
345+
content =
346+
if (requestCount == 1) {
347+
"""{"stored_id":"test","ip_version":6}"""
348+
} else {
349+
"""{"stored_id":"test"}"""
350+
},
351+
status = HttpStatusCode.OK,
352+
headers = headersOf(HttpHeaders.ContentType, "application/json"),
353+
)
354+
}
355+
val client = createTestClientWithDefaultServers(mockEngine)
356+
357+
client.sendDeviceData(testDeviceData)
358+
359+
assertEquals(2, capturedRequests.size)
360+
361+
// First request (IPv6) should NOT have request_duration
362+
val firstRequestJson = json.parseToJsonElement(capturedRequests[0].second).jsonObject
363+
assertNull(firstRequestJson["request_duration"])
364+
365+
// Second request (IPv4) SHOULD have request_duration
366+
val secondRequestJson = json.parseToJsonElement(capturedRequests[1].second).jsonObject
367+
assertNotNull(secondRequestJson["request_duration"])
368+
assertTrue(secondRequestJson["request_duration"]!!.jsonPrimitive.float >= 0)
369+
client.close()
370+
}
371+
372+
@Test
373+
internal fun `dual request sends to correct IPv6 and IPv4 endpoints`() =
374+
runTest {
375+
val capturedUrls = mutableListOf<String>()
376+
var requestCount = 0
377+
378+
val mockEngine =
379+
MockEngine { request ->
380+
requestCount++
381+
capturedUrls.add(request.url.toString())
382+
respond(
383+
content =
384+
if (requestCount == 1) {
385+
"""{"stored_id":"test","ip_version":6}"""
386+
} else {
387+
"""{"stored_id":"test"}"""
388+
},
389+
status = HttpStatusCode.OK,
390+
headers = headersOf(HttpHeaders.ContentType, "application/json"),
391+
)
392+
}
393+
val client = createTestClientWithDefaultServers(mockEngine)
394+
395+
client.sendDeviceData(testDeviceData)
396+
397+
assertEquals(2, capturedUrls.size)
398+
assertTrue(capturedUrls[0].contains("d-ipv6.mmapiws.com"))
399+
assertTrue(capturedUrls[1].contains("d-ipv4.mmapiws.com"))
400+
client.close()
401+
}
402+
403+
@Test
404+
internal fun `dual request skips IPv4 when ip_version is not 6`() =
405+
runTest {
406+
var requestCount = 0
407+
408+
val mockEngine =
409+
MockEngine { _ ->
410+
requestCount++
411+
respond(
412+
content = """{"stored_id":"test","ip_version":4}""",
413+
status = HttpStatusCode.OK,
414+
headers = headersOf(HttpHeaders.ContentType, "application/json"),
415+
)
416+
}
417+
val client = createTestClientWithDefaultServers(mockEngine)
418+
419+
client.sendDeviceData(testDeviceData)
420+
421+
assertEquals(1, requestCount)
422+
client.close()
423+
}
424+
331425
// ========== Helper Functions ==========
332426

333427
private fun createTestClient(
@@ -354,4 +448,27 @@ internal class DeviceApiClientTest {
354448
.build()
355449
return DeviceApiClient(config, httpClient)
356450
}
451+
452+
private fun createTestClientWithDefaultServers(
453+
mockEngine: MockEngine,
454+
accountID: Int = 99999,
455+
): DeviceApiClient {
456+
val httpClient =
457+
HttpClient(mockEngine) {
458+
install(ContentNegotiation) {
459+
json(
460+
Json {
461+
prettyPrint = true
462+
isLenient = true
463+
ignoreUnknownKeys = true
464+
},
465+
)
466+
}
467+
}
468+
val config =
469+
SdkConfig
470+
.Builder(accountID)
471+
.build()
472+
return DeviceApiClient(config, httpClient)
473+
}
357474
}

0 commit comments

Comments
 (0)