From 66ccb6a672a4f7974d082906a7620515998da380 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 15:29:40 -0700 Subject: [PATCH 1/5] feat: [SDK-5374] add sessions API backend client Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/onesignal/session/SessionModule.kt | 3 + .../backend/ISessionsBackendService.kt | 61 ++++++ .../backend/impl/SessionsBackendService.kt | 89 ++++++++ .../backend/SessionsBackendServiceTests.kt | 196 ++++++++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt index baa4ef105c..1dc8cc9888 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt @@ -18,6 +18,8 @@ import com.onesignal.session.internal.outcomes.impl.OutcomeEventsPreferences import com.onesignal.session.internal.outcomes.impl.OutcomeEventsRepository import com.onesignal.session.internal.session.ISessionService import com.onesignal.session.internal.session.SessionModelStore +import com.onesignal.session.internal.session.backend.ISessionsBackendService +import com.onesignal.session.internal.session.backend.impl.SessionsBackendService import com.onesignal.session.internal.session.impl.SessionListener import com.onesignal.session.internal.session.impl.SessionService @@ -43,5 +45,6 @@ internal class SessionModule : IModule { .provides() builder.register().provides() builder.register().provides() + builder.register().provides() } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt new file mode 100644 index 0000000000..49e2cc2353 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt @@ -0,0 +1,61 @@ +package com.onesignal.session.internal.session.backend + +/** + * The backend service for the public sessions API. + */ +internal interface ISessionsBackendService { + /** + * Create a session. On success the result holds the backend session ID. + */ + suspend fun createSession( + appId: String, + request: CreateSessionRequest, + ): SessionsApiResult + + /** + * Update an existing session with its cumulative duration, and end it when [UpdateSessionRequest.endTime] is set. + */ + suspend fun updateSession( + appId: String, + sessionId: String, + request: UpdateSessionRequest, + ): SessionsApiResult +} + +internal data class CreateSessionRequest( + val onesignalId: String, + val subscriptionId: String, + val deviceType: Int, + /** Unix time in seconds. */ + val startTime: Long, + val idempotencyKey: String, + val directAttributionId: String? = null, +) + +internal data class UpdateSessionRequest( + val onesignalId: String, + val subscriptionId: String, + val durationSeconds: Long, + val idempotencyKey: String, + /** Unix time in seconds. */ + val endTime: Long? = null, +) + +internal sealed class SessionsApiResult { + data class Success(val value: T) : SessionsApiResult() + + /** + * Transient failure: network error, 5xx, 408, or 429. Wait at least [retryAfterSeconds] when set. + */ + data class Retry( + val statusCode: Int, + val retryAfterSeconds: Int?, + ) : SessionsApiResult() + + /** + * Permanent failure (any other 4xx, or an unparseable success); the request should not be retried. + */ + data class Drop( + val statusCode: Int, + ) : SessionsApiResult() +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt new file mode 100644 index 0000000000..bb3f74b506 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt @@ -0,0 +1,89 @@ +package com.onesignal.session.internal.session.backend.impl + +import com.onesignal.common.safeJSONObject +import com.onesignal.common.safeString +import com.onesignal.core.internal.http.HttpResponse +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.session.internal.session.backend.CreateSessionRequest +import com.onesignal.session.internal.session.backend.ISessionsBackendService +import com.onesignal.session.internal.session.backend.SessionsApiResult +import com.onesignal.session.internal.session.backend.UpdateSessionRequest +import org.json.JSONException +import org.json.JSONObject + +internal class SessionsBackendService( + private val httpClient: IHttpClient, +) : ISessionsBackendService { + override suspend fun createSession( + appId: String, + request: CreateSessionRequest, + ): SessionsApiResult { + val body = + JSONObject() + .put("onesignal_id", request.onesignalId) + .put("subscription_id", request.subscriptionId) + .put("device_type", request.deviceType) + .put("start_time", request.startTime) + .put("idempotency_key", request.idempotencyKey) + request.directAttributionId?.let { body.put("direct_attribution_id", it) } + + val response = httpClient.post("apps/$appId/sessions", body) + if (!response.isSuccess) { + return classifyFailure(response) + } + + val sessionId = parseSessionId(response.payload) + return if (sessionId.isNullOrEmpty()) { + Logging.warn("SessionsBackendService: create session response is missing data.session_id") + SessionsApiResult.Drop(response.statusCode) + } else { + SessionsApiResult.Success(sessionId) + } + } + + override suspend fun updateSession( + appId: String, + sessionId: String, + request: UpdateSessionRequest, + ): SessionsApiResult { + val body = + JSONObject() + .put("onesignal_id", request.onesignalId) + .put("subscription_id", request.subscriptionId) + .put("duration_seconds", request.durationSeconds) + .put("idempotency_key", request.idempotencyKey) + request.endTime?.let { body.put("end_time", it) } + + val response = httpClient.patch("apps/$appId/sessions/$sessionId", body) + if (!response.isSuccess) { + return classifyFailure(response) + } + return SessionsApiResult.Success(Unit) + } + + private fun parseSessionId(payload: String?): String? = + try { + payload?.let { JSONObject(it) }?.safeJSONObject("data")?.safeString("session_id") + } catch (e: JSONException) { + Logging.warn("SessionsBackendService: unable to parse create session response", e) + null + } + + private fun classifyFailure(response: HttpResponse): SessionsApiResult { + val code = response.statusCode + // Status 0 means the request never got a response (network error, timeout). + val retryable = code == 0 || code == HTTP_REQUEST_TIMEOUT || code == HTTP_TOO_MANY_REQUESTS || code >= HTTP_SERVER_ERROR + return if (retryable) { + SessionsApiResult.Retry(code, response.retryAfterSeconds) + } else { + SessionsApiResult.Drop(code) + } + } + + private companion object { + const val HTTP_REQUEST_TIMEOUT = 408 + const val HTTP_TOO_MANY_REQUESTS = 429 + const val HTTP_SERVER_ERROR = 500 + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt new file mode 100644 index 0000000000..0dad64a2db --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt @@ -0,0 +1,196 @@ +package com.onesignal.session.internal.session.backend + +import com.onesignal.core.internal.http.HttpResponse +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.session.internal.session.backend.impl.SessionsBackendService +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk + +private val createRequest = + CreateSessionRequest( + onesignalId = "onesignalId", + subscriptionId = "subscriptionId", + deviceType = 1, + startTime = 1_700_000_000L, + idempotencyKey = "create-key", + ) + +private val updateRequest = + UpdateSessionRequest( + onesignalId = "onesignalId", + subscriptionId = "subscriptionId", + durationSeconds = 42L, + idempotencyKey = "update-key", + ) + +class SessionsBackendServiceTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("create session posts the request body") { + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""") + val service = SessionsBackendService(http) + + // When + service.createSession("appId", createRequest) + + // Then + coVerify { + http.post( + "apps/appId/sessions", + withArg { + it.getString("onesignal_id") shouldBe "onesignalId" + it.getString("subscription_id") shouldBe "subscriptionId" + it.getInt("device_type") shouldBe 1 + it.getLong("start_time") shouldBe 1_700_000_000L + it.getString("idempotency_key") shouldBe "create-key" + it.has("direct_attribution_id") shouldBe false + }, + ) + } + } + + test("create session includes direct attribution when set") { + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""") + val service = SessionsBackendService(http) + + // When + service.createSession("appId", createRequest.copy(directAttributionId = "notificationId")) + + // Then + coVerify { + http.post( + "apps/appId/sessions", + withArg { it.getString("direct_attribution_id") shouldBe "notificationId" }, + ) + } + } + + test("create session returns the session ID from a 202") { + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""") + val service = SessionsBackendService(http) + + // When + val result = service.createSession("appId", createRequest) + + // Then + result shouldBe SessionsApiResult.Success("server-id") + } + + test("create session drops a success response without a session ID") { + listOf( + null, + "", + "not json", + "{}", + """{"data":{}}""", + """{"data":{"session_id":""}}""", + ).forEach { payload -> + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, payload) + val service = SessionsBackendService(http) + + // When + val result = service.createSession("appId", createRequest) + + // Then + result shouldBe SessionsApiResult.Drop(202) + } + } + + test("update session patches the request body") { + // Given + val http = mockk() + coEvery { http.patch(any(), any()) } returns HttpResponse(202, null) + val service = SessionsBackendService(http) + + // When + val result = service.updateSession("appId", "server-id", updateRequest) + + // Then + result shouldBe SessionsApiResult.Success(Unit) + coVerify { + http.patch( + "apps/appId/sessions/server-id", + withArg { + it.getString("onesignal_id") shouldBe "onesignalId" + it.getString("subscription_id") shouldBe "subscriptionId" + it.getLong("duration_seconds") shouldBe 42L + it.getString("idempotency_key") shouldBe "update-key" + it.has("end_time") shouldBe false + }, + ) + } + } + + test("update session includes end time when set") { + // Given + val http = mockk() + coEvery { http.patch(any(), any()) } returns HttpResponse(202, null) + val service = SessionsBackendService(http) + + // When + service.updateSession("appId", "server-id", updateRequest.copy(endTime = 1_700_000_042L)) + + // Then + coVerify { + http.patch( + "apps/appId/sessions/server-id", + withArg { it.getLong("end_time") shouldBe 1_700_000_042L }, + ) + } + } + + test("failures are retried on network error, 5xx, 408, and 429") { + listOf(0, 408, 429, 500, 502, 503).forEach { statusCode -> + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, null) + coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, null) + val service = SessionsBackendService(http) + + // When / Then + service.createSession("appId", createRequest) shouldBe SessionsApiResult.Retry(statusCode, null) + service.updateSession("appId", "server-id", updateRequest) shouldBe SessionsApiResult.Retry(statusCode, null) + } + } + + test("other 4xx failures are dropped") { + listOf(400, 401, 403, 404, 409, 410, 422).forEach { statusCode -> + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, null, retryAfterSeconds = 10) + coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, null, retryAfterSeconds = 10) + val service = SessionsBackendService(http) + + // When / Then + service.createSession("appId", createRequest) shouldBe SessionsApiResult.Drop(statusCode) + service.updateSession("appId", "server-id", updateRequest) shouldBe SessionsApiResult.Drop(statusCode) + } + } + + test("retry exposes Retry-After from the response") { + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(429, null, retryAfterSeconds = 30) + coEvery { http.patch(any(), any()) } returns HttpResponse(503, null, retryAfterSeconds = 15) + val service = SessionsBackendService(http) + + // When / Then + service.createSession("appId", createRequest) shouldBe SessionsApiResult.Retry(429, 30) + service.updateSession("appId", "server-id", updateRequest) shouldBe SessionsApiResult.Retry(503, 15) + } +}) From 721a4a473c62878c095e9b536ad3d99be71a781b Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 6 Oct 2026 15:51:31 -0700 Subject: [PATCH 2/5] fix: [SDK-5374] retry network errors and session-less create responses Co-Authored-By: Claude Opus 5.5 (1M context) --- .../session/backend/ISessionsBackendService.kt | 5 +++-- .../session/backend/impl/SessionsBackendService.kt | 11 +++++------ .../session/backend/SessionsBackendServiceTests.kt | 9 ++++++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt index 49e2cc2353..887f0b5905 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt @@ -45,7 +45,8 @@ internal sealed class SessionsApiResult { data class Success(val value: T) : SessionsApiResult() /** - * Transient failure: network error, 5xx, 408, or 429. Wait at least [retryAfterSeconds] when set. + * Transient failure: network error, 5xx, 408, 429, or a create success without a session ID. + * Wait at least [retryAfterSeconds] when set. */ data class Retry( val statusCode: Int, @@ -53,7 +54,7 @@ internal sealed class SessionsApiResult { ) : SessionsApiResult() /** - * Permanent failure (any other 4xx, or an unparseable success); the request should not be retried. + * Permanent failure (any other 4xx); the request should not be retried. */ data class Drop( val statusCode: Int, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt index bb3f74b506..632ce19554 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt @@ -1,7 +1,5 @@ package com.onesignal.session.internal.session.backend.impl -import com.onesignal.common.safeJSONObject -import com.onesignal.common.safeString import com.onesignal.core.internal.http.HttpResponse import com.onesignal.core.internal.http.IHttpClient import com.onesignal.debug.internal.logging.Logging @@ -35,8 +33,9 @@ internal class SessionsBackendService( val sessionId = parseSessionId(response.payload) return if (sessionId.isNullOrEmpty()) { + // Retrying with the same idempotency key lets the backend return the session it already created. Logging.warn("SessionsBackendService: create session response is missing data.session_id") - SessionsApiResult.Drop(response.statusCode) + SessionsApiResult.Retry(response.statusCode, response.retryAfterSeconds) } else { SessionsApiResult.Success(sessionId) } @@ -64,7 +63,7 @@ internal class SessionsBackendService( private fun parseSessionId(payload: String?): String? = try { - payload?.let { JSONObject(it) }?.safeJSONObject("data")?.safeString("session_id") + payload?.let { JSONObject(it) }?.optJSONObject("data")?.opt("session_id") as? String } catch (e: JSONException) { Logging.warn("SessionsBackendService: unable to parse create session response", e) null @@ -72,8 +71,8 @@ internal class SessionsBackendService( private fun classifyFailure(response: HttpResponse): SessionsApiResult { val code = response.statusCode - // Status 0 means the request never got a response (network error, timeout). - val retryable = code == 0 || code == HTTP_REQUEST_TIMEOUT || code == HTTP_TOO_MANY_REQUESTS || code >= HTTP_SERVER_ERROR + // Non-positive codes mean no HTTP response: HttpClient returns -1 for network errors and 0 for timeouts or missing consent. + val retryable = code <= 0 || code == HTTP_REQUEST_TIMEOUT || code == HTTP_TOO_MANY_REQUESTS || code >= HTTP_SERVER_ERROR return if (retryable) { SessionsApiResult.Retry(code, response.retryAfterSeconds) } else { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt index 0dad64a2db..9a5fb8736c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt @@ -89,7 +89,7 @@ class SessionsBackendServiceTests : FunSpec({ result shouldBe SessionsApiResult.Success("server-id") } - test("create session drops a success response without a session ID") { + test("create session retries a success response without a session ID") { listOf( null, "", @@ -97,6 +97,9 @@ class SessionsBackendServiceTests : FunSpec({ "{}", """{"data":{}}""", """{"data":{"session_id":""}}""", + """{"data":{"session_id":null}}""", + """{"data":{"session_id":123}}""", + """{"data":"server-id"}""", ).forEach { payload -> // Given val http = mockk() @@ -107,7 +110,7 @@ class SessionsBackendServiceTests : FunSpec({ val result = service.createSession("appId", createRequest) // Then - result shouldBe SessionsApiResult.Drop(202) + result shouldBe SessionsApiResult.Retry(202, null) } } @@ -155,7 +158,7 @@ class SessionsBackendServiceTests : FunSpec({ } test("failures are retried on network error, 5xx, 408, and 429") { - listOf(0, 408, 429, 500, 502, 503).forEach { statusCode -> + listOf(-1, 0, 408, 429, 500, 502, 503).forEach { statusCode -> // Given val http = mockk() coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, null) From 2cda85cce23f39f8135c4dc935bf2c9c358a336b Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 16:46:17 -0700 Subject: [PATCH 3/5] fix: [SDK-5374] send session timestamps as ISO 8601 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../session/backend/ISessionsBackendService.kt | 4 ++-- .../backend/impl/SessionsBackendService.kt | 15 +++++++++++++-- .../backend/SessionsBackendServiceTests.kt | 4 ++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt index 887f0b5905..858d96101b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt @@ -26,7 +26,7 @@ internal data class CreateSessionRequest( val onesignalId: String, val subscriptionId: String, val deviceType: Int, - /** Unix time in seconds. */ + /** Unix time in seconds, sent as ISO 8601 UTC. */ val startTime: Long, val idempotencyKey: String, val directAttributionId: String? = null, @@ -37,7 +37,7 @@ internal data class UpdateSessionRequest( val subscriptionId: String, val durationSeconds: Long, val idempotencyKey: String, - /** Unix time in seconds. */ + /** Unix time in seconds, sent as ISO 8601 UTC. */ val endTime: Long? = null, ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt index 632ce19554..45038367da 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt @@ -9,6 +9,10 @@ import com.onesignal.session.internal.session.backend.SessionsApiResult import com.onesignal.session.internal.session.backend.UpdateSessionRequest import org.json.JSONException import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone internal class SessionsBackendService( private val httpClient: IHttpClient, @@ -22,7 +26,7 @@ internal class SessionsBackendService( .put("onesignal_id", request.onesignalId) .put("subscription_id", request.subscriptionId) .put("device_type", request.deviceType) - .put("start_time", request.startTime) + .put("start_time", toIso8601(request.startTime)) .put("idempotency_key", request.idempotencyKey) request.directAttributionId?.let { body.put("direct_attribution_id", it) } @@ -52,7 +56,7 @@ internal class SessionsBackendService( .put("subscription_id", request.subscriptionId) .put("duration_seconds", request.durationSeconds) .put("idempotency_key", request.idempotencyKey) - request.endTime?.let { body.put("end_time", it) } + request.endTime?.let { body.put("end_time", toIso8601(it)) } val response = httpClient.patch("apps/$appId/sessions/$sessionId", body) if (!response.isSuccess) { @@ -61,6 +65,12 @@ internal class SessionsBackendService( return SessionsApiResult.Success(Unit) } + // SimpleDateFormat instead of java.time, which needs API 26. + private fun toIso8601(epochSeconds: Long): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(Date(epochSeconds * MILLIS_PER_SECOND)) + private fun parseSessionId(payload: String?): String? = try { payload?.let { JSONObject(it) }?.optJSONObject("data")?.opt("session_id") as? String @@ -84,5 +94,6 @@ internal class SessionsBackendService( const val HTTP_REQUEST_TIMEOUT = 408 const val HTTP_TOO_MANY_REQUESTS = 429 const val HTTP_SERVER_ERROR = 500 + const val MILLIS_PER_SECOND = 1000L } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt index 9a5fb8736c..d7a547135b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt @@ -50,7 +50,7 @@ class SessionsBackendServiceTests : FunSpec({ it.getString("onesignal_id") shouldBe "onesignalId" it.getString("subscription_id") shouldBe "subscriptionId" it.getInt("device_type") shouldBe 1 - it.getLong("start_time") shouldBe 1_700_000_000L + it.getString("start_time") shouldBe "2023-11-14T22:13:20Z" it.getString("idempotency_key") shouldBe "create-key" it.has("direct_attribution_id") shouldBe false }, @@ -152,7 +152,7 @@ class SessionsBackendServiceTests : FunSpec({ coVerify { http.patch( "apps/appId/sessions/server-id", - withArg { it.getLong("end_time") shouldBe 1_700_000_042L }, + withArg { it.getString("end_time") shouldBe "2023-11-14T22:14:02Z" }, ) } } From ef3a955927420a5082bf55952fc9d15e3d3fa59c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 16:48:41 -0700 Subject: [PATCH 4/5] refactor: [SDK-5374] reuse DateUtils for session timestamps Co-Authored-By: Claude Opus 5.5 (1M context) --- .../session/backend/impl/SessionsBackendService.kt | 10 ++-------- .../session/backend/SessionsBackendServiceTests.kt | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt index 45038367da..91abfdd15b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt @@ -1,5 +1,6 @@ package com.onesignal.session.internal.session.backend.impl +import com.onesignal.common.DateUtils import com.onesignal.core.internal.http.HttpResponse import com.onesignal.core.internal.http.IHttpClient import com.onesignal.debug.internal.logging.Logging @@ -9,10 +10,7 @@ import com.onesignal.session.internal.session.backend.SessionsApiResult import com.onesignal.session.internal.session.backend.UpdateSessionRequest import org.json.JSONException import org.json.JSONObject -import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale -import java.util.TimeZone internal class SessionsBackendService( private val httpClient: IHttpClient, @@ -65,11 +63,7 @@ internal class SessionsBackendService( return SessionsApiResult.Success(Unit) } - // SimpleDateFormat instead of java.time, which needs API 26. - private fun toIso8601(epochSeconds: Long): String = - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) - .apply { timeZone = TimeZone.getTimeZone("UTC") } - .format(Date(epochSeconds * MILLIS_PER_SECOND)) + private fun toIso8601(epochSeconds: Long): String = DateUtils.iso8601Format().format(Date(epochSeconds * MILLIS_PER_SECOND)) private fun parseSessionId(payload: String?): String? = try { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt index d7a547135b..88a6354f2f 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt @@ -50,7 +50,7 @@ class SessionsBackendServiceTests : FunSpec({ it.getString("onesignal_id") shouldBe "onesignalId" it.getString("subscription_id") shouldBe "subscriptionId" it.getInt("device_type") shouldBe 1 - it.getString("start_time") shouldBe "2023-11-14T22:13:20Z" + it.getString("start_time") shouldBe "2023-11-14T22:13:20.000Z" it.getString("idempotency_key") shouldBe "create-key" it.has("direct_attribution_id") shouldBe false }, @@ -152,7 +152,7 @@ class SessionsBackendServiceTests : FunSpec({ coVerify { http.patch( "apps/appId/sessions/server-id", - withArg { it.getString("end_time") shouldBe "2023-11-14T22:14:02Z" }, + withArg { it.getString("end_time") shouldBe "2023-11-14T22:14:02.000Z" }, ) } } From ca3be87749d47ae30fff4ab29d8948ed5f57ce30 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 7 Oct 2026 16:58:03 -0700 Subject: [PATCH 5/5] refactor: [SDK-5374] throw BackendException and leave classification to the caller Co-Authored-By: Claude Opus 5.5 (1M context) --- .../backend/ISessionsBackendService.kt | 32 +++-------- .../backend/impl/SessionsBackendService.kt | 38 +++---------- .../backend/SessionsBackendServiceTests.kt | 56 +++++++------------ 3 files changed, 36 insertions(+), 90 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt index 858d96101b..9ca5869a4d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt @@ -1,16 +1,22 @@ package com.onesignal.session.internal.session.backend +import com.onesignal.common.exceptions.BackendException + /** * The backend service for the public sessions API. + * + * If there is a non-successful response from the backend, a [BackendException] will be thrown with response data. */ internal interface ISessionsBackendService { /** - * Create a session. On success the result holds the backend session ID. + * Create a session. + * + * @return The backend session ID, or null if the success response did not include one. */ suspend fun createSession( appId: String, request: CreateSessionRequest, - ): SessionsApiResult + ): String? /** * Update an existing session with its cumulative duration, and end it when [UpdateSessionRequest.endTime] is set. @@ -19,7 +25,7 @@ internal interface ISessionsBackendService { appId: String, sessionId: String, request: UpdateSessionRequest, - ): SessionsApiResult + ) } internal data class CreateSessionRequest( @@ -40,23 +46,3 @@ internal data class UpdateSessionRequest( /** Unix time in seconds, sent as ISO 8601 UTC. */ val endTime: Long? = null, ) - -internal sealed class SessionsApiResult { - data class Success(val value: T) : SessionsApiResult() - - /** - * Transient failure: network error, 5xx, 408, 429, or a create success without a session ID. - * Wait at least [retryAfterSeconds] when set. - */ - data class Retry( - val statusCode: Int, - val retryAfterSeconds: Int?, - ) : SessionsApiResult() - - /** - * Permanent failure (any other 4xx); the request should not be retried. - */ - data class Drop( - val statusCode: Int, - ) : SessionsApiResult() -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt index 91abfdd15b..c3220ca199 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt @@ -1,12 +1,11 @@ package com.onesignal.session.internal.session.backend.impl import com.onesignal.common.DateUtils -import com.onesignal.core.internal.http.HttpResponse +import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.http.IHttpClient import com.onesignal.debug.internal.logging.Logging import com.onesignal.session.internal.session.backend.CreateSessionRequest import com.onesignal.session.internal.session.backend.ISessionsBackendService -import com.onesignal.session.internal.session.backend.SessionsApiResult import com.onesignal.session.internal.session.backend.UpdateSessionRequest import org.json.JSONException import org.json.JSONObject @@ -18,7 +17,7 @@ internal class SessionsBackendService( override suspend fun createSession( appId: String, request: CreateSessionRequest, - ): SessionsApiResult { + ): String? { val body = JSONObject() .put("onesignal_id", request.onesignalId) @@ -30,24 +29,17 @@ internal class SessionsBackendService( val response = httpClient.post("apps/$appId/sessions", body) if (!response.isSuccess) { - return classifyFailure(response) + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } - val sessionId = parseSessionId(response.payload) - return if (sessionId.isNullOrEmpty()) { - // Retrying with the same idempotency key lets the backend return the session it already created. - Logging.warn("SessionsBackendService: create session response is missing data.session_id") - SessionsApiResult.Retry(response.statusCode, response.retryAfterSeconds) - } else { - SessionsApiResult.Success(sessionId) - } + return parseSessionId(response.payload) } override suspend fun updateSession( appId: String, sessionId: String, request: UpdateSessionRequest, - ): SessionsApiResult { + ) { val body = JSONObject() .put("onesignal_id", request.onesignalId) @@ -58,36 +50,22 @@ internal class SessionsBackendService( val response = httpClient.patch("apps/$appId/sessions/$sessionId", body) if (!response.isSuccess) { - return classifyFailure(response) + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } - return SessionsApiResult.Success(Unit) } private fun toIso8601(epochSeconds: Long): String = DateUtils.iso8601Format().format(Date(epochSeconds * MILLIS_PER_SECOND)) private fun parseSessionId(payload: String?): String? = try { - payload?.let { JSONObject(it) }?.optJSONObject("data")?.opt("session_id") as? String + val sessionId = payload?.let { JSONObject(it) }?.optJSONObject("data")?.opt("session_id") as? String + sessionId?.takeIf { it.isNotEmpty() } } catch (e: JSONException) { Logging.warn("SessionsBackendService: unable to parse create session response", e) null } - private fun classifyFailure(response: HttpResponse): SessionsApiResult { - val code = response.statusCode - // Non-positive codes mean no HTTP response: HttpClient returns -1 for network errors and 0 for timeouts or missing consent. - val retryable = code <= 0 || code == HTTP_REQUEST_TIMEOUT || code == HTTP_TOO_MANY_REQUESTS || code >= HTTP_SERVER_ERROR - return if (retryable) { - SessionsApiResult.Retry(code, response.retryAfterSeconds) - } else { - SessionsApiResult.Drop(code) - } - } - private companion object { - const val HTTP_REQUEST_TIMEOUT = 408 - const val HTTP_TOO_MANY_REQUESTS = 429 - const val HTTP_SERVER_ERROR = 500 const val MILLIS_PER_SECOND = 1000L } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt index 88a6354f2f..646852b968 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt @@ -1,10 +1,12 @@ package com.onesignal.session.internal.session.backend +import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.http.HttpResponse import com.onesignal.core.internal.http.IHttpClient import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.session.internal.session.backend.impl.SessionsBackendService +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.coEvery @@ -86,10 +88,10 @@ class SessionsBackendServiceTests : FunSpec({ val result = service.createSession("appId", createRequest) // Then - result shouldBe SessionsApiResult.Success("server-id") + result shouldBe "server-id" } - test("create session retries a success response without a session ID") { + test("create session returns null for a success response without a session ID") { listOf( null, "", @@ -110,7 +112,7 @@ class SessionsBackendServiceTests : FunSpec({ val result = service.createSession("appId", createRequest) // Then - result shouldBe SessionsApiResult.Retry(202, null) + result shouldBe null } } @@ -121,10 +123,9 @@ class SessionsBackendServiceTests : FunSpec({ val service = SessionsBackendService(http) // When - val result = service.updateSession("appId", "server-id", updateRequest) + service.updateSession("appId", "server-id", updateRequest) // Then - result shouldBe SessionsApiResult.Success(Unit) coVerify { http.patch( "apps/appId/sessions/server-id", @@ -157,43 +158,24 @@ class SessionsBackendServiceTests : FunSpec({ } } - test("failures are retried on network error, 5xx, 408, and 429") { - listOf(-1, 0, 408, 429, 500, 502, 503).forEach { statusCode -> + test("failures throw BackendException with the status code and Retry-After") { + listOf(-1, 0, 400, 404, 422, 429, 503).forEach { statusCode -> // Given val http = mockk() - coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, null) - coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, null) + coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30) + coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30) val service = SessionsBackendService(http) - // When / Then - service.createSession("appId", createRequest) shouldBe SessionsApiResult.Retry(statusCode, null) - service.updateSession("appId", "server-id", updateRequest) shouldBe SessionsApiResult.Retry(statusCode, null) - } - } - - test("other 4xx failures are dropped") { - listOf(400, 401, 403, 404, 409, 410, 422).forEach { statusCode -> - // Given - val http = mockk() - coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, null, retryAfterSeconds = 10) - coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, null, retryAfterSeconds = 10) - val service = SessionsBackendService(http) + // When + val createException = shouldThrow { service.createSession("appId", createRequest) } + val updateException = shouldThrow { service.updateSession("appId", "server-id", updateRequest) } - // When / Then - service.createSession("appId", createRequest) shouldBe SessionsApiResult.Drop(statusCode) - service.updateSession("appId", "server-id", updateRequest) shouldBe SessionsApiResult.Drop(statusCode) + // Then + listOf(createException, updateException).forEach { + it.statusCode shouldBe statusCode + it.response shouldBe "error" + it.retryAfterSeconds shouldBe 30 + } } } - - test("retry exposes Retry-After from the response") { - // Given - val http = mockk() - coEvery { http.post(any(), any()) } returns HttpResponse(429, null, retryAfterSeconds = 30) - coEvery { http.patch(any(), any()) } returns HttpResponse(503, null, retryAfterSeconds = 15) - val service = SessionsBackendService(http) - - // When / Then - service.createSession("appId", createRequest) shouldBe SessionsApiResult.Retry(429, 30) - service.updateSession("appId", "server-id", updateRequest) shouldBe SessionsApiResult.Retry(503, 15) - } })