Rede em Kotlin para conexões lentas: timeouts e tentativas seguras
Kotlin prático para redes lentas: defina timeouts, faça cache seguro, re-tente sem duplicar e proteja ações críticas em redes móveis instáveis.

O que quebra em conexões lentas e instáveis
No mobile, “lento” geralmente não quer dizer “sem internet”. Muitas vezes é uma conexão que funciona, mas só em curtos surtos. Uma requisição pode levar 8 a 20 segundos, travar no meio e depois finalizar. Ou pode funcionar num momento e falhar no seguinte porque o celular mudou de Wi‑Fi para LTE, entrou numa área de sinal fraco ou o sistema operacional colocou o app em segundo plano.
“Instável” é pior. Pacotes caem, consultas DNS expiram, handshakes TLS falham e conexões são resetadas ao acaso. Você pode escrever tudo “certo” no código e ainda ver falhas em campo porque a rede muda sob seus pés.
É aí que as configurações padrão tendem a falhar. Muitos apps confiam nos padrões das bibliotecas para timeouts, retries e cache sem decidir o que é “bom o suficiente” para pessoas reais. Defaults frequentemente são otimizados para Wi‑Fi estável e APIs rápidas, não para um trem de comutador, um elevador ou uma cafeteria cheia.
Usuários não descrevem “socket timeouts” ou “HTTP 503.” Eles notam sintomas: spinners infinitos, erros súbitos depois de longa espera (e depois funciona na próxima tentativa), ações duplicadas (duas reservas, dois pedidos, cobranças em dobro), atualizações perdidas e estados mistos onde a UI diz “falhou” mas o servidor na verdade teve sucesso.
Redes lentas transformam pequenas falhas de design em problemas de dinheiro e confiança. Se o app não separa claramente “ainda enviando” de “falhou” de “concluído”, os usuários tocam de novo. Se o cliente re-tenta sem critério, pode criar duplicatas. Se o servidor não tem suporte para idempotência, uma conexão instável pode gerar várias gravações “bem‑sucedidas”.
“Ações críticas” são tudo que deve acontecer no máximo uma vez e estar correto: pagamentos, submissão de checkout, reservar um horário, transferir pontos, trocar senha, salvar endereço de entrega, enviar um pedido de reembolso ou aprovar algo.
Um exemplo realista: alguém envia o checkout com LTE fraco. O app manda a requisição e a conexão cai antes da resposta chegar. O usuário vê um erro, toca em “Pagar” de novo, e agora duas requisições chegam ao servidor. Sem regras claras, o app não sabe se deve re-tentar, esperar ou parar. O usuário também não sabe se deve tentar de novo.
Decida suas regras antes de ajustar o código
Quando conexões são lentas ou instáveis, a maioria dos bugs vem de regras pouco claras, não do cliente HTTP. Antes de mexer em timeouts, cache ou retries, escreva o que “correto” significa para o seu app.
Comece pelas ações que nunca devem rodar duas vezes. Normalmente são questões de dinheiro e conta: fazer pedido, cobrar cartão, pagar, trocar senha, apagar conta. Se um usuário tocar duas vezes ou o app re-tentar, o servidor ainda deve tratar como uma única requisição. Se você não pode garantir isso ainda, trate esses endpoints como “sem retry automático” até resolver.
Depois, decida o que cada tela pode fazer quando a rede estiver ruim. Algumas telas ainda podem ser úteis offline (perfil conhecido, pedidos anteriores). Outras devem ficar somente leitura ou mostrar um estado claro de “tente novamente” (contagem de estoque, preços ao vivo). Misturar essas expectativas leva a UI confusa e cache arriscado.
Defina o tempo de espera aceitável por ação com base em como os usuários pensam, não no que parece elegante no código. Login tolera uma espera curta. Upload de arquivo precisa de mais tempo. Checkout deve parecer rápido, mas também seguro. Um timeout de 30 segundos pode ser “confiável” no papel e ainda assim parecer quebrado.
Por fim, decida o que vai armazenar no dispositivo e por quanto tempo. Dados em cache ajudam, mas informação velha pode levar a escolhas erradas (preços antigos, elegibilidade expirada).
Escreva as regras em algum lugar que todo mundo encontre (um README já serve). Mantenha simples:
- Quais endpoints são “não duplicáveis” e exigem manejo de idempotência?
- Quais telas devem funcionar offline, e quais são somente leitura quando offline?
- Qual é o tempo máximo de espera por ação (login, atualizar feed, upload, checkout)?
- O que pode ser cacheado no dispositivo e qual é o tempo de expiração?
- Depois de falhar, você mostra erro, enfileira para depois ou exige tentativa manual?
Com essas regras claras, seus valores de timeout, cabeçalhos de cache, política de retry e estados de UI ficam muito mais fáceis de implementar e testar.
Timeouts que batem com as expectativas reais do usuário
Redes lentas falham de formas diferentes. Um bom setup de timeouts não “escolhe um número”. Ele casa com o que o usuário está tentando fazer e falha rápido o bastante para o app poder se recuperar.
Os três timeouts, em termos simples:
- Connect timeout: quanto tempo você espera para estabelecer a conexão com o servidor (consulta DNS, TCP, TLS). Se isso falhar, a requisição nem começou direito.
- Write timeout: quanto tempo você espera ao enviar o corpo da requisição (uploads, JSON grande, uplink lento).
- Read timeout: quanto tempo você espera pelo servidor começar a enviar dados depois que a requisição foi enviada. Isso aparece muito em redes móveis instáveis.
Timeouts devem refletir a tela e os riscos. Um feed pode ser mais lento sem grande dano. Uma ação crítica deve completar ou falhar claramente para que o usuário decida o próximo passo.
Um ponto de partida prático (ajuste após medir):
- Carregamento de listas (baixo risco): connect 5–10s, read 20–30s, write 10–15s.
- Busca enquanto digita: connect 3–5s, read 5–10s, write 5–10s.
- Ações críticas (alto risco, como “Pagar” ou “Enviar pedido”): connect 5–10s, read 30–60s, write 15–30s.
Consistência importa mais que perfeição. Se o usuário tocar “Enviar” e ver um spinner por dois minutos, ele vai tocar de novo.
Evite “carregamento infinito” também na interface: adicione um limite superior na UI. Mostre progresso imediatamente, permita cancelar e, após (por exemplo) 20–30 segundos mostre “Ainda tentando…” com opções para reconectar ou tentar novamente. Isso mantém a experiência honesta mesmo se a biblioteca de rede ainda estiver esperando.
Quando um timeout ocorre, registre dados suficientes para diagnosticar padrões depois, sem logar segredos. Campos úteis incluem o caminho da URL (não a query completa), método HTTP, status (se houver), um detalhamento de tempos (connect vs write vs read se disponível), tipo de rede (Wi‑Fi, celular, modo avião), tamanho aproximado da requisição/resposta e um ID de requisição para cruzar com logs do servidor.
Uma configuração simples e consistente em Kotlin
Em conexões lentas, pequenas inconsistências na configuração do cliente viram grandes problemas. Uma base limpa ajuda a debugar mais rápido e dá a todas as requisições as mesmas regras.
Um cliente, uma política
Comece com um único lugar onde você constrói seu cliente HTTP (normalmente um OkHttpClient usado pelo Retrofit). Coloque o básico ali para que todas as requisições se comportem igual: headers padrão (versão do app, locale, token de auth) e um User‑Agent claro, timeouts definidos uma vez (não espalhados), logging que você pode ativar para debugar e uma decisão única sobre retries (mesmo que seja “nenhuma retry automática”).
Aqui está um pequeno exemplo que mantém a configuração num só arquivo:
val okHttp = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.callTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("User-Agent", "MyApp/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json")
.build()
chain.proceed(request)
}
.build()
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(MoshiConverterFactory.create())
.build()
Tratamento central de erros que vira mensagens ao usuário
Erros de rede não são só “uma exceção”. Se cada tela lidar diferente, os usuários recebem mensagens aleatórias.
Crie um mapeador que converta falhas em um pequeno conjunto de resultados amigáveis ao usuário: sem conexão/modo avião, timeout, erro de servidor (5xx), erro de validação ou auth (4xx) e um fallback desconhecido.
Isso mantém o texto da UI consistente (“Sem conexão” vs “Tente novamente”) sem vazar detalhes técnicos.
Marcar e cancelar requisições quando a tela fecha
Em redes instáveis, chamadas podem terminar tarde e atualizar uma tela que já foi fechada. Faça o cancelamento uma regra padrão: quando a tela fecha, o trabalho dela para.
Com Retrofit e coroutines, cancelar o scope da coroutine (por exemplo no ViewModel) cancela a chamada HTTP subjacente. Para chamadas sem coroutines, mantenha a referência ao Call e chame cancel(). Você também pode taggear requisições e cancelar grupos quando uma feature for encerrada.
Trabalho em background não deve depender da UI
Qualquer coisa importante que precisa terminar (enviar um relatório, sincronizar uma fila, completar uma submissão) deve rodar em um scheduler apropriado. No Android, WorkManager é a escolha comum porque consegue re-tentar depois e sobreviver a reinícios do app. Mantenha ações de UI leves e delegue trabalhos mais longos para jobs de background quando fizer sentido.
Regras de cache seguras para mobile
Cachear pode ser uma grande vantagem em redes lentas porque reduz downloads repetidos e faz telas parecerem instantâneas. Também pode ser problema se apresentar dados obsoletos no momento errado, como saldo antigo ou endereço de entrega desatualizado.
Uma abordagem segura é cachear apenas o que o usuário tolera ficar um pouco velho e forçar checagens frescas para tudo que afeta dinheiro, segurança ou decisão final.
Noções básicas de Cache‑Control em que você pode confiar
A maioria das regras se resume a alguns cabeçalhos:
max-age=60: reutilize a resposta em cache por 60 segundos sem perguntar ao servidor.no-store: não guarde esta resposta em lugar nenhum (melhor para tokens e telas sensíveis).must-revalidate: se expirou, você deve checar com o servidor antes de usar novamente.
No mobile, must-revalidate evita dados “silenciosamente errados” após um período offline temporário. Se o usuário abrir o app depois de uma viagem de metrô, você quer uma tela rápida, mas também quer confirmar o que ainda é válido.
Atualizações por ETag: rápidas, baratas e confiáveis
Para endpoints de leitura, validação baseada em ETag muitas vezes é melhor que max-age longo. O servidor envia um ETag com a resposta. Na próxima vez, o app envia If-None-Match com esse valor. Se nada mudou, o servidor responde 304 Not Modified, que é pequeno e rápido em redes fracas.
Isso funciona bem para listas de produtos, detalhes de perfil e telas de configurações.
Uma regra simples:
- Cacheie endpoints de “leitura” com
max-agecurto maismust-revalidate, e suporteETagquando possível. - Não cacheie endpoints de “escrita” (POST/PUT/PATCH/DELETE). Considere‑os sempre ligados à rede.
- Use
no-storepara qualquer coisa sensível (respostas de auth, passos de pagamento, mensagens privadas). - Cacheie assets estáticos (ícones, config pública) por mais tempo, já que o risco de estar desatualizado é baixo.
Mantenha decisões de cache consistentes no app. Usuários notam divergências mais que pequenos atrasos.
Retentativas seguras sem piorar a situação
Retries parecem uma solução fácil, mas podem sair pela culatra. Retente as requisições erradas e você cria carga extra, drena bateria e faz o app ficar preso. Retente no lugar errado e duplicará trabalho no servidor.
Comece retentando apenas falhas que provavelmente são temporárias. Uma conexão perdida, um read timeout ou uma queda rápida do servidor podem funcionar na próxima tentativa. Senha errada, campo faltando ou 404 não vão.
Um conjunto prático de regras:
- Retente timeouts e falhas de conexão.
- Retente 502, 503 e às vezes 504.
- Não retente 4xx (exceto 408 ou 429, se você tiver uma regra clara de espera).
- Não retente requisições que já alcançaram o servidor e podem estar sendo processadas.
- Mantenha poucas tentativas (frequentemente 1 a 3).
Backoff + jitter: menos ondas de retry
Se muitos usuários atingem a mesma queda, retries instantâneos podem criar uma onda de tráfego que atrasa a recuperação. Use backoff exponencial (esperar mais a cada tentativa) e adicione jitter (um pequeno atraso aleatório) para que dispositivos não re-tentem em sincronia.
Exemplo: espere ~0,5s, depois 1s, depois 2s, com um ajuste aleatório de ±20% cada vez.
Defina um teto para o tempo total de retentativas
Sem limites, retries podem prender usuários em um spinner por minutos. Escolha um tempo máximo total para a operação inteira, incluindo todas as esperas. Muitos apps miram 10 a 20 segundos antes de parar e mostrar uma opção clara de tentar novamente.
Também combine com o contexto. Se alguém está submetendo um formulário, quer resposta rápida. Se é uma sincronização em background, você pode re-tentar depois.
Nunca re-tente automaticamente ações não idempotentes (como fazer um pedido ou enviar um pagamento) a menos que tenha proteção como uma chave de idempotência ou checagem de duplicatas no servidor. Se não puder garantir segurança, falhe claramente e deixe o usuário decidir o próximo passo.
Prevenção de duplicatas para ações críticas
Em conexão lenta ou instável, usuários tocam duas vezes. O SO pode re-tentar em background. Seu app pode re-enviar após um timeout. Se a ação é “criar algo” (fazer pedido, enviar dinheiro, trocar senha), duplicatas podem causar danos.
Idempotência significa que a mesma requisição deve produzir o mesmo resultado. Se a requisição se repetir, o servidor não deve criar um segundo pedido — deve retornar o primeiro resultado ou dizer “já feito”.
Use uma chave de idempotência para cada tentativa crítica
Para ações críticas, gere uma chave de idempotência única quando o usuário iniciar a tentativa e envie-a na requisição (geralmente num header como Idempotency-Key ou num campo do corpo).
Fluxo prático:
- Crie um UUID como chave de idempotência quando o usuário tocar “Pagar”.
- Salve localmente um pequeno registro: status = pending, createdAt, hash do payload.
- Envie a requisição com a chave.
- Ao receber sucesso, marque status = done e salve o ID do resultado retornado pelo servidor.
- Se precisar re-tentar, reuse a mesma chave, não uma nova.
Reusar a mesma chave é o que impede cobranças acidentais em dobro.
Trate reinícios do app e lacunas offline
Se o app for morto no meio da requisição, o próximo lançamento ainda precisa ser seguro. Armazene a chave de idempotência e o estado da requisição no armazenamento local (por exemplo uma linha pequena no banco). No reinício, ou re-tente com a mesma chave ou consulte um endpoint de “check status” usando a chave salva ou o ID do resultado do servidor.
No servidor, o contrato deve ser claro: quando receber uma chave duplicada, rejeitar a segunda tentativa ou retornar a resposta original (mesmo order ID, mesmo recibo). Se o servidor não suporta isso, prevenção de duplicatas só no cliente nunca será totalmente confiável, porque o app não vê o que aconteceu depois que enviou a requisição.
Um toque amigo na UX: se uma tentativa estiver pendente, mostre “Pagamento em andamento” e desabilite o botão até obter um resultado final.
Padrões de UI que reduzem reenvios acidentais
Redes lentas não só quebram requisições. Elas mudam a maneira como as pessoas tocam. Quando a tela congela por dois segundos, muitos usuários assumem que nada aconteceu e batem no botão de novo. Sua UI tem que fazer um “toque único” parecer confiável mesmo com a rede ruim.
UI otimista é segura quando a ação é reversível ou de baixo risco, como favoritar um item, salvar rascunho ou marcar uma mensagem como lida. UI confirmada é melhor para dinheiro, estoque, deletes irreversíveis e qualquer coisa que gere duplicatas.
Um padrão bom para ações críticas é um estado claro de pendência. Depois do primeiro toque, troque imediatamente o botão primário para “Enviando…”, desabilite‑o e mostre uma linha curta explicando o que está acontecendo.
Padrões que funcionam em redes instáveis:
- Desabilite a ação principal após o toque e mantenha desabilitada até ter resultado final.
- Mostre um status visível de “Pendente” com detalhes (valor, destinatário, quantidade).
- Adicione uma visão de “Atividade recente” para o usuário confirmar o que já enviou.
- Se o app for enviado para background, mantenha o estado pendente ao retornar.
- Prefira um botão primário claro em vez de múltiplos alvos de toque na mesma tela.
Às vezes a requisição chega mas a resposta se perde. Trate isso como um resultado normal, não como um erro que convida a tocar de novo. Em vez de “Falhou, tente novamente”, mostre “Ainda não sabemos” e ofereça um próximo passo seguro como “Verificar status”. Se não puder checar o status, mantenha o registro pendente localmente e diga ao usuário que vai atualizar quando a conexão voltar.
Faça o “Tentar novamente” explícito e seguro. Só mostre quando você puder repetir a requisição usando o mesmo ID de cliente ou chave de idempotência.
Exemplo realista: um envio de checkout instável
Um cliente está num trem com sinal intermitente. Adiciona itens ao carrinho e toca em Pagar. O app precisa ter paciência, mas também não pode criar dois pedidos.
Uma sequência segura fica assim:
- O app cria um ID de tentativa no cliente e envia o pedido de checkout com uma chave de idempotência (por exemplo, um UUID salvo com o carrinho).
- A requisição aguarda um connect timeout claro e depois um read timeout mais longo. O trem entra no túnel e a chamada expira.
- O app re-tenta uma vez, mas apenas após um pequeno atraso e só se não tiver recebido resposta do servidor.
- O servidor recebe a segunda requisição e vê a mesma chave de idempotência, então retorna o resultado original em vez de criar outro pedido.
- O app mostra a tela de confirmação final ao receber a resposta de sucesso, mesmo que tenha vindo da retentativa.
Cache segue regras estritas. Listas de produtos, opções de entrega e tabelas de impostos podem ser cacheadas por pouco tempo (GET). A submissão do checkout (POST) nunca é cacheada. Mesmo que você use um cache HTTP, trate‑o como ajuda de leitura para navegação, não algo que “lembra” um pagamento.
Prevenção de duplicatas é mistura de escolhas de rede e UI. Quando o usuário toca em Pagar, o botão é desabilitado e a tela mostra “Enviando pedido...” com uma única opção Cancelar. Se o app perde rede, muda para “Ainda tentando” e mantém o mesmo ID de tentativa. Se o usuário fechar forçadamente e reabrir, o app pode retomar checando o status do pedido com aquele ID, em vez de pedir que pague de novo.
Checklist rápido e próximos passos
Se seu app funciona “mais ou menos” em Wi‑Fi de escritório mas desaba em trens, elevadores ou áreas rurais, trate isso como um gate de lançamento. Esse trabalho é menos sobre código esperto e mais sobre regras claras que você pode reproduzir.
Checklist antes de lançar:
- Defina timeouts por tipo de endpoint (login, feed, upload, checkout) e teste em redes com throttling e alta latência.
- Retente apenas onde for realmente seguro, e limite com backoff (algumas tentativas para leituras, geralmente nenhuma para writes).
- Adicione uma chave de idempotência para todo write crítico (pagamentos, pedidos, submissões) para que uma retentativa ou duplo toque não crie duplicatas.
- Deixe regras de cache explícitas: o que pode ficar stale, o que deve ser sempre fresco e o que nunca deve ser cacheado.
- Torne estados visíveis: pendente, falhado e concluído devem ter aparências distintas, e o app deve lembrar ações concluídas após reinício.
Se algum desses itens estiver “decidiremos depois”, você acabará com comportamento aleatório entre telas.
Próximos passos para cimentar isso
Escreva uma política de networking de uma página: categorias de endpoint, metas de timeout, regras de retry e expectativas de cache. Aplique isso num só lugar (interceptors, uma fábrica de cliente compartilhada ou um pequeno wrapper) para que todo time tenha o mesmo comportamento por padrão.
Então faça um drill de duplicação. Escolha uma ação crítica (como checkout), simule um spinner congelado, force‑close o app, alterne modo avião e pressione o botão de novo. Se você não conseguir provar que é seguro, os usuários eventualmente vão achar uma forma de quebrar.
Se quiser implementar as mesmas regras entre backend e clientes sem fio manual, AppMaster (appmaster.io) pode ajudar gerando backend pronto para produção e código nativo mobile. Mesmo assim, o essencial é a política: defina idempotência, retries, cache e estados de UI uma vez e aplique de forma consistente em todo o fluxo.
FAQ
Comece definindo o que “correto” significa para cada tela e ação — especialmente tudo que deve ocorrer no máximo uma vez, como pagamentos ou pedidos. Depois de deixar as regras claras, ajuste timeouts, tentativas, cache e estados de UI para corresponder a elas, em vez de confiar nos padrões da biblioteca.
Normalmente os usuários veem spinners infinitos, erros depois de longa espera, ações que funcionam na segunda tentativa ou resultados duplicados como dois pedidos ou cobranças em dobro. Esses sintomas costumam vir de regras pouco claras sobre retries e sobre quando algo está “pendente” versus “falhou”, não só do sinal ruim.
Use o connect timeout para quanto tempo espera para estabelecer a conexão, o write timeout para enviar o corpo da requisição (uploads) e o read timeout para aguardar a resposta depois de enviar. Um bom padrão é timeouts mais curtos para leituras de baixo risco e tempos maiores para submissões críticas, e sempre ter um limite claro na UI para não deixar o usuário esperando para sempre.
Sim. Se só pode ajustar um, use callTimeout para limitar a operação inteira de ponta a ponta e evitar esperas “infinitas”. Depois adicione connect/read/write conforme necessário para ter controle melhor, especialmente em uploads e respostas grandes.
Retente apenas falhas temporárias como quedas de conexão, problemas de DNS e timeouts, e às vezes 502/503/504. Evite retentar erros 4xx e não faça retries automáticos em writes a menos que tenha proteção de idempotência, porque retentativas podem criar duplicatas.
Use poucas tentativas (normalmente 1–3) com backoff exponencial e um pequeno jitter aleatório para evitar picos sincronizados. Também limite o tempo total gasto em retries para que o usuário receba um resultado claro em vez de um spinner que dura minutos.
Idempotência significa que repetir a mesma requisição não cria um segundo resultado — assim um duplo toque ou retry não causa cobrança ou reserva duplicada. Para ações críticas, envie uma chave de idempotência por tentativa e reaproveite-a nas retentativas para que o servidor retorne o resultado original em vez de criar outro.
Gere uma chave única quando o usuário iniciar a ação, armazene-a localmente com um pequeno registro “pendente” e envie-a com a requisição. Se precisar re-tentar ou o app for reiniciado, reutilize a mesma chave e ou reenvie com segurança ou consulte o status para não transformar uma intenção do usuário em duas gravações no servidor.
Armazene apenas dados que possam ficar um pouco desatualizados sem causar problemas, e force checagens frescas para dinheiro, segurança e decisões finais. Para leituras, prefira validade curta mais revalidação e considere ETags; para writes, não faça cache, e use no-store para respostas sensíveis.
Desabilite o botão principal após o primeiro toque, mostre um estado imediato de “Enviando…” e mantenha um status pendente visível que sobreviva a backgrounding ou reinícios. Se a resposta pode ter se perdido, não incentive tentativas repetidas — mostre incerteza (“Ainda não sabemos”) e ofereça uma ação segura, como checar o status.


