diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
-## [*Unreleased*](https://github.com/frontrowed/faktory_worker_haskell/compare/v1.1.0.0...main)
+## [*Unreleased*](https://github.com/frontrowed/faktory_worker_haskell/compare/v1.1.1.0...main)
 
 None
+
+## [v1.1.1.0](https://github.com/frontrowed/faktory_worker_haskell/compare/v1.1.0.1...v1.1.1.0)
+
+- Add `jobRemainingRetries`
 
 ## [v1.1.0.0](https://github.com/frontrowed/faktory_worker_haskell/compare/v1.0.3.1...v1.1.0.0)
 
diff --git a/faktory.cabal b/faktory.cabal
--- a/faktory.cabal
+++ b/faktory.cabal
@@ -1,6 +1,6 @@
 cabal-version:   1.18
 name:            faktory
-version:         1.1.0.0
+version:         1.1.1.0
 license:         MIT
 license-file:    LICENSE
 copyright:       2018 Freckle Education
@@ -58,6 +58,7 @@
         Faktory.Ent.Tracking
         Faktory.Job
         Faktory.Job.Custom
+        Faktory.JobFailure
         Faktory.JobOptions
         Faktory.JobState
         Faktory.Prelude
@@ -178,6 +179,7 @@
         Faktory.Ent.BatchSpec
         Faktory.Ent.TrackingSpec
         Faktory.JobOptionsSpec
+        Faktory.JobSpec
         Faktory.Test
         FaktorySpec
         Paths_faktory
@@ -200,6 +202,7 @@
 
     build-depends:
         aeson >=1.5.5.1,
+        aeson-qq >=0.8.3,
         async >=2.2.2,
         base >=4.13 && <5,
         faktory -any,
diff --git a/library/Faktory/Job.hs b/library/Faktory/Job.hs
--- a/library/Faktory/Job.hs
+++ b/library/Faktory/Job.hs
@@ -15,6 +15,7 @@
   , jobJid
   , jobArg
   , jobOptions
+  , jobRetriesRemaining
   ) where
 
 import Faktory.Prelude
@@ -22,9 +23,11 @@
 import Data.Aeson
 import Data.List.NonEmpty (NonEmpty)
 import qualified Data.List.NonEmpty as NE
+import Data.Semigroup (Last(..))
 import Data.Time (UTCTime)
 import Faktory.Client (Client(..))
 import Faktory.Connection (ConnectionInfo(..))
+import Faktory.JobFailure
 import Faktory.JobOptions
 import Faktory.Producer (Producer(..), pushJob)
 import Faktory.Settings (Namespace, Settings(..))
@@ -39,6 +42,7 @@
   -- ^ Faktory needs to serialize args as a list, but we like a single-argument
   -- interface so that's what we expose. See @'jobArg'@.
   , jobOptions :: JobOptions
+  , jobFailure :: Maybe JobFailure
   }
 
 -- | Perform a Job with the given options
@@ -85,11 +89,18 @@
     , jobAt = Nothing
     , jobArgs = pure arg
     , jobOptions = jobtype "Default"
+    , jobFailure = Nothing
     }
 
 jobArg :: Job arg -> arg
 jobArg Job {..} = NE.head jobArgs
 
+jobRetriesRemaining :: Job arg -> Int
+jobRetriesRemaining job = max 0 $ enqueuedRetry - attemptCount
+ where
+  enqueuedRetry = maybe faktoryDefaultRetry getLast $ joRetry $ jobOptions job
+  attemptCount = maybe 0 ((+ 1) . jfRetryCount) $ jobFailure job
+
 instance ToJSON args => ToJSON (Job args) where
   toJSON = object . toPairs
   toEncoding = pairs . mconcat . toPairs
@@ -113,5 +124,13 @@
     <*> o .:? "at"
     <*> o .: "args"
     <*> parseJSON (Object o)
+    <*> o .:? "failure"
 
 type JobId = String
+
+-- | https://github.com/contribsys/faktory/wiki/Job-Errors#the-process
+--
+-- > By default Faktory will retry a job 25 times
+--
+faktoryDefaultRetry :: Int
+faktoryDefaultRetry = 25
diff --git a/library/Faktory/JobFailure.hs b/library/Faktory/JobFailure.hs
new file mode 100644
--- /dev/null
+++ b/library/Faktory/JobFailure.hs
@@ -0,0 +1,28 @@
+module Faktory.JobFailure
+  ( JobFailure(..)
+  ) where
+
+import Faktory.Prelude
+
+import Data.Aeson
+import Data.Time (UTCTime)
+
+data JobFailure = JobFailure
+  { jfRetryCount :: Int
+  , jfFailedAt :: UTCTime
+  , jfNextAt :: Maybe UTCTime
+  , jfErrorMessage :: Maybe Text
+  , jfErrorType :: Maybe Text
+  , jfBacktrace :: Maybe [Text]
+  }
+
+-- brittany-disable-next-binding
+
+instance FromJSON JobFailure where
+  parseJSON = withObject "Failure" $ \o -> JobFailure
+    <$> o .: "retry_count"
+    <*> o .: "failed_at"
+    <*> o .:? "next_at"
+    <*> o .:? "error_message"
+    <*> o .:? "error_type"
+    <*> o .:? "backtrace"
diff --git a/tests/Faktory/JobSpec.hs b/tests/Faktory/JobSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Faktory/JobSpec.hs
@@ -0,0 +1,132 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Faktory.JobSpec
+  ( spec
+  ) where
+
+import Faktory.Prelude
+
+import Data.Aeson
+import Data.Aeson.QQ
+import Data.Time (getCurrentTime)
+import Faktory.Job
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  -- https://github.com/contribsys/faktory/issues/374#issuecomment-902075572
+  describe "jobRetriesRemaining" $ do
+    it "handles first consumed" $ do
+      job <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": 2
+        }
+      |]
+
+      jobRetriesRemaining job `shouldBe` 2
+
+    it "handles a first retry" $ do
+      now <- getCurrentTime
+      job <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": 2
+        , "failure":
+          { "retry_count": 0
+          , "failed_at": #{now}
+          }
+        }
+      |]
+
+      jobRetriesRemaining job `shouldBe` 1
+
+    it "handles a final retry" $ do
+      now <- getCurrentTime
+      job <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": 2
+        , "failure":
+          { "retry_count": 1
+          , "failed_at": #{now}
+          }
+        }
+      |]
+
+      jobRetriesRemaining job `shouldBe` 0
+
+    it "uses Faktory's default" $ do
+      job <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        }
+      |]
+
+      jobRetriesRemaining job `shouldBe` 25
+
+    it "handles retry -1" $ do
+      now <- getCurrentTime
+      job1 <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": -1
+        }
+      |]
+      job2 <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": -1
+        , "failure":
+          { "retry_count": 1
+          , "failed_at": #{now}
+          }
+        }
+      |]
+
+      jobRetriesRemaining job1 `shouldBe` 0
+      jobRetriesRemaining job2 `shouldBe` 0
+
+    it "handles retry 0" $ do
+      now <- getCurrentTime
+      job1 <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": 0
+        }
+      |]
+      job2 <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": 0
+        , "failure":
+          { "retry_count": 1
+          , "failed_at": #{now}
+          }
+        }
+      |]
+
+      jobRetriesRemaining job1 `shouldBe` 0
+      jobRetriesRemaining job2 `shouldBe` 0
+
+    it "handles nonsense" $ do
+      now <- getCurrentTime
+      job <- decodeJob [aesonQQ|
+        { "jid": "abc"
+        , "args": [""]
+        , "retry": 20
+        , "failure":
+          { "retry_count": 1000
+          , "failed_at": #{now}
+          }
+        }
+      |]
+
+      jobRetriesRemaining job `shouldBe` 0
+
+decodeJob :: Value -> IO (Job Text)
+decodeJob v = case fromJSON v of
+  Error err -> do
+    expectationFailure $ "Job JSON did not parse: " <> err
+    error "unreachable" -- I hate that hspec makes ^ IO () and not IO a
+  Success job -> pure job
