Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ on:

jobs:
lint:
timeout-minutes: 10
timeout-minutes: 15
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/lithic-java' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
Expand All @@ -37,7 +37,7 @@ jobs:
- name: Run lints
run: ./scripts/lint
test:
timeout-minutes: 10
timeout-minutes: 15
name: test
runs-on: ${{ github.repository == 'stainless-sdks/lithic-java' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.98.0"
".": "0.99.0"
}
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 0.99.0 (2025-07-31)

Full Changelog: [v0.98.0...v0.99.0](https://github.com/lithic-com/lithic-java/compare/v0.98.0...v0.99.0)

### Features

* add retryable exception ([9d0e333](https://github.com/lithic-com/lithic-java/commit/9d0e333fa86f6ca6feafd1ea77663082212d4fbd))
* **client:** ensure compat with proguard ([0cf8803](https://github.com/lithic-com/lithic-java/commit/0cf88037c4534479827ab86f6cfc4e01aa6c9f5e))


### Bug Fixes

* **client:** r8 support ([3f77652](https://github.com/lithic-com/lithic-java/commit/3f776524d6c643f14ce1e4a29d5a61919028d27e))


### Chores

* **internal:** bump ci test timeout ([dc67b43](https://github.com/lithic-com/lithic-java/commit/dc67b43b859593e8a7a302e2d74935fa45e11584))
* **internal:** reduce proguard ci logging ([7af8774](https://github.com/lithic-com/lithic-java/commit/7af8774633df28cc77bf86cf3bde7073bc1caf04))

## 0.98.0 (2025-07-28)

Full Changelog: [v0.97.1...v0.98.0](https://github.com/lithic-com/lithic-java/compare/v0.97.1...v0.98.0)
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

<!-- x-release-please-start-version -->

[![Maven Central](https://img.shields.io/maven-central/v/com.lithic.api/lithic-java)](https://central.sonatype.com/artifact/com.lithic.api/lithic-java/0.98.0)
[![javadoc](https://javadoc.io/badge2/com.lithic.api/lithic-java/0.98.0/javadoc.svg)](https://javadoc.io/doc/com.lithic.api/lithic-java/0.98.0)
[![Maven Central](https://img.shields.io/maven-central/v/com.lithic.api/lithic-java)](https://central.sonatype.com/artifact/com.lithic.api/lithic-java/0.99.0)
[![javadoc](https://javadoc.io/badge2/com.lithic.api/lithic-java/0.99.0/javadoc.svg)](https://javadoc.io/doc/com.lithic.api/lithic-java/0.99.0)

<!-- x-release-please-end -->

Expand All @@ -13,7 +13,7 @@ The Lithic Java SDK is similar to the Lithic Kotlin SDK but with minor differenc

<!-- x-release-please-start-version -->

The REST API documentation can be found on [docs.lithic.com](https://docs.lithic.com). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.lithic.api/lithic-java/0.98.0).
The REST API documentation can be found on [docs.lithic.com](https://docs.lithic.com). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.lithic.api/lithic-java/0.99.0).

<!-- x-release-please-end -->

Expand All @@ -24,7 +24,7 @@ The REST API documentation can be found on [docs.lithic.com](https://docs.lithic
### Gradle

```kotlin
implementation("com.lithic.api:lithic-java:0.98.0")
implementation("com.lithic.api:lithic-java:0.99.0")
```

### Maven
Expand All @@ -33,7 +33,7 @@ implementation("com.lithic.api:lithic-java:0.98.0")
<dependency>
<groupId>com.lithic.api</groupId>
<artifactId>lithic-java</artifactId>
<version>0.98.0</version>
<version>0.99.0</version>
</dependency>
```

Expand Down Expand Up @@ -232,6 +232,8 @@ The SDK throws custom unchecked exception types:

- [`LithicIoException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicIoException.kt): I/O networking errors.

- [`LithicRetryableException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicRetryableException.kt): Generic error indicating a failure that could be retried by the client.

- [`LithicInvalidDataException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicInvalidDataException.kt): Failure to interpret successfully parsed data. For example, when accessing a property that's supposed to be required, but the API unexpectedly omitted it from the response.

- [`LithicException`](lithic-java-core/src/main/kotlin/com/lithic/api/errors/LithicException.kt): Base class for all exceptions. Most errors will result in one of the previously mentioned ones, but completely generic errors may be thrown using the base class.
Expand Down Expand Up @@ -362,6 +364,12 @@ both of which will raise an error if the signature is invalid.
Note that the "body" parameter must be the raw JSON string sent from the server (do not parse it first).
The `.unwrap()` method can parse this JSON for you.

## ProGuard and R8

Although the SDK uses reflection, it is still usable with [ProGuard](https://github.com/Guardsquare/proguard) and [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization) because `lithic-java-core` is published with a [configuration file](lithic-java-core/src/main/resources/META-INF/proguard/lithic-java-core.pro) containing [keep rules](https://www.guardsquare.com/manual/configuration/usage).

ProGuard and R8 should automatically detect and use the published rules, but you can also manually copy the keep rules if necessary.

## Jackson

The SDK depends on [Jackson](https://github.com/FasterXML/jackson) for JSON serialization/deserialization. It is compatible with version 2.13.4 or higher, but depends on version 2.18.2 by default.
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ repositories {

allprojects {
group = "com.lithic.api"
version = "0.98.0" // x-release-please-version
version = "0.99.0" // x-release-please-version
}

subprojects {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.lithic.api.core.http
import com.lithic.api.core.RequestOptions
import com.lithic.api.core.checkRequired
import com.lithic.api.errors.LithicIoException
import com.lithic.api.errors.LithicRetryableException
import java.io.IOException
import java.time.Clock
import java.time.Duration
Expand Down Expand Up @@ -176,9 +177,10 @@ private constructor(
}

private fun shouldRetry(throwable: Throwable): Boolean =
// Only retry IOException and LithicIoException, other exceptions are not intended to be
// retried.
throwable is IOException || throwable is LithicIoException
// Only retry known retryable exceptions, other exceptions are not intended to be retried.
throwable is IOException ||
throwable is LithicIoException ||
throwable is LithicRetryableException

private fun getRetryBackoffDuration(retries: Int, response: HttpResponse?): Duration {
// About the Retry-After header:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.lithic.api.errors

/**
* Exception that indicates a transient error that can be retried.
*
* When this exception is thrown during an HTTP request, the SDK will automatically retry the
* request up to the maximum number of retries.
*
* @param message A descriptive error message
* @param cause The underlying cause of this exception, if any
*/
class LithicRetryableException
@JvmOverloads
constructor(message: String? = null, cause: Throwable? = null) : LithicException(message, cause)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Jackson uses reflection and depends heavily on runtime attributes.
-keepattributes Exceptions,InnerClasses,Signature,Deprecated,*Annotation*

# Jackson uses Kotlin reflection utilities, which themselves use reflection to access things.
-keep class kotlin.reflect.** { *; }
-keep class kotlin.Metadata { *; }

# Jackson uses reflection to access enum members (e.g. via `java.lang.Class.getEnumConstants()`).
-keepclassmembers class com.fasterxml.jackson.** extends java.lang.Enum {
<fields>;
public static **[] values();
public static ** valueOf(java.lang.String);
}

# Jackson uses reflection to access annotation members.
-keepclassmembers @interface com.fasterxml.jackson.annotation.** {
*;
}

# Jackson uses reified type information to serialize and deserialize our classes (via `TypeReference`).
-keep class com.fasterxml.jackson.core.type.TypeReference { *; }
-keep class * extends com.fasterxml.jackson.core.type.TypeReference { *; }

# Jackson uses reflection to access our class serializers and deserializers.
-keep @com.fasterxml.jackson.databind.annotation.JsonSerialize class com.lithic.api.** { *; }
-keep @com.fasterxml.jackson.databind.annotation.JsonDeserialize class com.lithic.api.** { *; }

# Jackson uses reflection to serialize and deserialize our classes based on their constructors and annotated members.
-keepclassmembers class com.lithic.api.** {
<init>(...);
@com.fasterxml.jackson.annotation.* *;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest
import com.github.tomakehurst.wiremock.stubbing.Scenario
import com.lithic.api.client.okhttp.OkHttpClient
import com.lithic.api.core.RequestOptions
import com.lithic.api.errors.LithicRetryableException
import java.io.InputStream
import java.time.Duration
import java.util.concurrent.CompletableFuture
Expand Down Expand Up @@ -251,6 +252,82 @@ internal class RetryingHttpClientTest {
assertNoResponseLeaks()
}

@ParameterizedTest
@ValueSource(booleans = [false, true])
fun execute_withRetryableException(async: Boolean) {
stubFor(post(urlPathEqualTo("/something")).willReturn(ok()))

var callCount = 0
val failingHttpClient =
object : HttpClient {
override fun execute(
request: HttpRequest,
requestOptions: RequestOptions,
): HttpResponse {
callCount++
if (callCount == 1) {
throw LithicRetryableException("Simulated retryable failure")
}
return httpClient.execute(request, requestOptions)
}

override fun executeAsync(
request: HttpRequest,
requestOptions: RequestOptions,
): CompletableFuture<HttpResponse> {
callCount++
if (callCount == 1) {
val future = CompletableFuture<HttpResponse>()
future.completeExceptionally(
LithicRetryableException("Simulated retryable failure")
)
return future
}
return httpClient.executeAsync(request, requestOptions)
}

override fun close() = httpClient.close()
}

val retryingClient =
RetryingHttpClient.builder()
.httpClient(failingHttpClient)
.maxRetries(2)
.sleeper(
object : RetryingHttpClient.Sleeper {

override fun sleep(duration: Duration) {}

override fun sleepAsync(duration: Duration): CompletableFuture<Void> =
CompletableFuture.completedFuture(null)
}
)
.build()

val response =
retryingClient.execute(
HttpRequest.builder()
.method(HttpMethod.POST)
.baseUrl(baseUrl)
.addPathSegment("something")
.build(),
async,
)

assertThat(response.statusCode()).isEqualTo(200)
verify(
1,
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("1")),
)
verify(
0,
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("0")),
)
assertNoResponseLeaks()
}

private fun retryingHttpClientBuilder() =
RetryingHttpClient.builder()
.httpClient(httpClient)
Expand Down
103 changes: 103 additions & 0 deletions lithic-java-proguard-test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
plugins {
id("lithic.kotlin")
id("com.gradleup.shadow") version "8.3.8"
}

buildscript {
repositories {
google()
}

dependencies {
classpath("com.guardsquare:proguard-gradle:7.4.2")
classpath("com.android.tools:r8:8.3.37")
}
}

dependencies {
testImplementation(project(":lithic-java"))
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3")
testImplementation("org.assertj:assertj-core:3.25.3")
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.4")
}

tasks.shadowJar {
from(sourceSets.test.get().output)
configurations = listOf(project.configurations.testRuntimeClasspath.get())
}

val proguardJarPath = "${layout.buildDirectory.get()}/libs/${project.name}-${project.version}-proguard.jar"
val proguardJar by tasks.registering(proguard.gradle.ProGuardTask::class) {
group = "verification"
dependsOn(tasks.shadowJar)
notCompatibleWithConfigurationCache("ProGuard")

injars(tasks.shadowJar)
outjars(proguardJarPath)
printmapping("${layout.buildDirectory.get()}/proguard-mapping.txt")

dontwarn()

val javaHome = System.getProperty("java.home")
if (System.getProperty("java.version").startsWith("1.")) {
// Before Java 9, the runtime classes were packaged in a single jar file.
libraryjars("$javaHome/lib/rt.jar")
} else {
// As of Java 9, the runtime classes are packaged in modular jmod files.
libraryjars(
// Filters must be specified first, as a map.
mapOf("jarfilter" to "!**.jar", "filter" to "!module-info.class"),
"$javaHome/jmods/java.base.jmod"
)
}

configuration("./test.pro")
configuration("../lithic-java-core/src/main/resources/META-INF/proguard/lithic-java-core.pro")
}

val testProGuard by tasks.registering(JavaExec::class) {
group = "verification"
dependsOn(proguardJar)
notCompatibleWithConfigurationCache("ProGuard")

mainClass.set("com.lithic.api.proguard.ProGuardCompatibilityTest")
classpath = files(proguardJarPath)
}

val r8JarPath = "${layout.buildDirectory.get()}/libs/${project.name}-${project.version}-r8.jar"
val r8Jar by tasks.registering(JavaExec::class) {
group = "verification"
dependsOn(tasks.shadowJar)
notCompatibleWithConfigurationCache("R8")

mainClass.set("com.android.tools.r8.R8")
classpath = buildscript.configurations["classpath"]

args = listOf(
"--release",
"--classfile",
"--output", r8JarPath,
"--lib", System.getProperty("java.home"),
"--pg-conf", "./test.pro",
"--pg-conf", "../lithic-java-core/src/main/resources/META-INF/proguard/lithic-java-core.pro",
"--pg-map-output", "${layout.buildDirectory.get()}/r8-mapping.txt",
tasks.shadowJar.get().archiveFile.get().asFile.absolutePath,
)
}

val testR8 by tasks.registering(JavaExec::class) {
group = "verification"
dependsOn(r8Jar)
notCompatibleWithConfigurationCache("R8")

mainClass.set("com.lithic.api.proguard.ProGuardCompatibilityTest")
classpath = files(r8JarPath)
}

tasks.test {
dependsOn(testProGuard)
dependsOn(testR8)
// We defer to the tests run via the ProGuard JAR.
enabled = false
}
Loading
Loading