Email verification in Spring Boot

Spring Boot 3.2 brought RestClient, a synchronous client with a fluent API that does not drag WebFlux into a servlet application. It is the right default here, and RestTemplate still works if you are on an older release.

Configure the timeouts on the builder. Spring's defaults are the JDK's defaults, which means no read timeout, and a greylisting mail host will hold a Tomcat thread as long as it likes.

RestClient configured as a bean with explicit timeouts, plus a ConstraintValidator for the bean-validation path.

Verify one address

You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.

Java
@Configuration
public class ZapBounceConfig {

    @Bean
    RestClient zapBounceClient(RestClient.Builder builder,
                               @Value("${zapbounce.key}") String key) {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(Duration.ofSeconds(3));
        factory.setReadTimeout(Duration.ofSeconds(12));   // no default. Set it.

        return builder
                .baseUrl("https://app.zapbounce.com/api/v1")
                .defaultHeader("X-API-Key", key)
                .requestFactory(factory)
                .build();
    }
}

@Service
public class VerificationService {

    private final RestClient client;

    public VerificationService(RestClient client) { this.client = client; }

    public VerifyResult verify(String email) {
        return client.post()
                .uri("/verify")
                .body(Map.of("emails", List.of(email)))
                .retrieve()
                .onStatus(HttpStatusCode::isError, (req, res) -> {
                    throw ZapBounceException.from(res);
                })
                .body(VerifyResponse.class)
                .results().get(0);
    }
}

// The JSON is camelCase, so the record components bind with no naming strategy.
public record VerifyResponse(String node, List<VerifyResult> results) {}
public record VerifyResult(String id, String email, String status, String reasonCode,
                           String provider, List<String> flags, Integer smtpCode,
                           String checkedAt) {}

Verify a list

Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

Java
@Service
public class JobService {

    private final RestClient client;
    private final ContactRepository contacts;

    /** Returns once the job is submitted and finished. There is no webhook, so it polls. */
    @Async("verificationExecutor")
    public CompletableFuture<String> submitList(long listId) throws InterruptedException {
        List<String> emails = contacts.findUnverifiedEmails(listId);

        var created = client.post()
                .uri("/jobs")
                .header("Idempotency-Key", "list-" + listId)
                .body(Map.of("emails", emails, "source", "api"))   // up to 50,000 per job
                .retrieve()
                .body(JobCreated.class);
        contacts.recordJobId(listId, created.id());

        while (!Set.of("completed", "partial", "failed", "cancelled")
                   .contains(client.get().uri("/jobs/" + created.id()).retrieve().body(Job.class).status())) {
            Thread.sleep(10_000);
        }
        importResults(created.id());
        return CompletableFuture.completedFuture(created.id());
    }

    /** Page loop, writing in chunks so a million rows never land in one list. */
    public void importResults(String jobId) {
        int page = 1;
        ResultPage body;
        do {
            body = client.get()
                    .uri("/jobs/" + jobId + "/emails?page=" + page + "&pageSize=100")
                    .retrieve().body(ResultPage.class);
            contacts.applyVerdicts(body.items());
        } while (page++ < body.meta().totalPages());
    }
}

@Bean("verificationExecutor")
ThreadPoolTaskExecutor verificationExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setQueueCapacity(100);
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    return executor;
}

Errors and retries

A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.

Java
public class ZapBounceException extends RuntimeException {
    final String code;        // CREDITS_INSUFFICIENT, RATE_LIMITED, UNAUTHORIZED...
    final String requestId;
    final int status;
    final int windowSeconds;  // X-RateLimit-Window-Seconds; there is no Retry-After header

    static ZapBounceException from(ClientHttpResponse res) throws IOException {
        // Body: { statusCode, code, message, requestId }
        JsonNode body = new ObjectMapper().readTree(res.getBody());
        int window = Optional.ofNullable(res.getHeaders().getFirst("X-RateLimit-Window-Seconds"))
                .map(Integer::parseInt).orElse(0);
        return new ZapBounceException(body.path("code").asText(), body.path("message").asText(),
                body.path("requestId").asText(), res.getStatusCode().value(), window);
    }

    boolean retryable() { return status == 429 || status >= 500; }
}

@Service
public class ResilientVerification {

    private final VerificationService service;

    public VerifyResult verify(String email) throws InterruptedException {
        for (int attempt = 0; ; attempt++) {
            try {
                return service.verify(email);
            } catch (ZapBounceException e) {
                if (!e.retryable() || attempt >= 4) throw e;   // CREDITS_INSUFFICIENT (422) never retries
                long floorMs = e.status == 429 ? Math.max(1, e.windowSeconds) * 1000L : 500;
                Thread.sleep((long) (Math.pow(2, attempt) * floorMs * (0.5 + Math.random())));
            } catch (ResourceAccessException e) {
                // Timeout or connection failure: not a bad address. Fail open.
                return new VerifyResult("", email, "unknown", "TIMEOUT", null, List.of(), null, null);
            }
        }
    }
}

Spring Boot: common questions

RestClient or WebClient?

RestClient in a servlet application. WebClient pulls in the reactive stack, which is a large dependency for a handful of endpoints.

How do I test the validator?

Stub VerificationService to return a result whose status is unknown. That exercises the branch that otherwise only fires in production.

Should Resilience4j handle retries?

If you already use it, yes. @Retry with an exponential backoff on the service method does the job without the loop. Size the wait from X-RateLimit-Window-Seconds, because a 429 carries no Retry-After header.

Run this Spring Boot code today

100 free checks a month, no card, credits that never expire, and unknown results refunded.