diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f8d72addd5b40987096c2feb926b8dc940da4395
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,13 @@
+{
+  "language": "python",
+  "python": "2.7",
+  "install": [
+    "pip install -r requirements.txt"
+  ],
+  "script": [
+    "pytest"
+  ],
+  "group": "stable",
+  "dist": "trusty",
+  "os": "linux"
+}
diff --git a/README.md b/README.md
index e7c47de06a551658c714acfe8f82179934aded0f..d697d4ca40719b57848666d2a63644a3ea6863f6 100644
--- a/README.md
+++ b/README.md
@@ -8,16 +8,23 @@ The config-template.txt file should be renamed to config.txt, and modified with
 
 Every time the script runs, it will query an external service to retrieve the external IP of the machine, compare it to the current A record in the zone at gandi.net, and then update the record if the IP has changed.
 
-Requirements:
-  - Python 2.7
-  - ipaddress module (pip install ipaddress)
-  - requests module (pip install requests)
-  - json module (pip install json)
+Requirements: 
+
+  pip install -r requirements.txt
 
 You can then run the script as a cron job :
 
 ```
-*/15 * * * * python /home/user/gandi-ddns.py
+*/15 * * * * python /home/user/gandi_ddns.py
+```
+
+macOS
+
+```
+cd gandi-ddns
+ln -s $(pwd) /usr/local/gandi-ddns
+sudo cp gandi.ddns.plist /Library/LaunchDaemons/
+sudo launchctl /Library/LaunchDaemons/gandi.ddns.plist
 ```
 
 But to be nice to the API servers, you should choose a random offset, for example to run at 2 minutes after the hour, and then every 15 minutes :
diff --git a/config-template.txt b/config-template.txt
index 82ac92b9dd35aacc5d2914abdfed33e47b1de34e..fb46cffe2d60d55c5538ba8dc58f5e9d1053b241 100644
--- a/config-template.txt
+++ b/config-template.txt
@@ -14,3 +14,7 @@ ttl = 900
 
 # Production API
 api = https://dns.api.gandi.net/api/v5/
+
+# Number of retries for looking up our own IP address, since
+# the service seems to be flaky sometimes.
+retries = 3
diff --git a/gandi-ddns.py b/gandi-ddns.py
deleted file mode 100755
index 6bee5045e9dff056bd1e51b85fb585d18aed5074..0000000000000000000000000000000000000000
--- a/gandi-ddns.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import ConfigParser as configparser
-import sys
-import os
-import requests
-import json
-import ipaddress
-
-config_file = "config.txt"
-
-SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
-
-def get_ip():
-        #Get external IP
-        try:
-          # Could be any service that just gives us a simple raw ASCII IP address (not HTML etc)
-          r = requests.get('https://api.ipify.org', timeout=3)
-        except Exception:
-          print('Failed to retrieve external IP.')
-          sys.exit(2)
-	if r.status_code != 200:
-          print('Failed to retrieve external IP. Server responded with status_code: %d' % r.status_code)
-          sys.exit(2)
-
-        ip = r.text.rstrip() # strip \n and any trailing whitespace
-
-	if not(ipaddress.IPv4Address(ip)): # check if valid IPv4 address
-          sys.exit(2)
-
-        return ip
-
-def read_config(config_path):
-        #Read configuration file
-        cfg = configparser.ConfigParser()
-        cfg.read(config_path)
-
-        return cfg
-
-def get_record(url, headers):
-        #Get existing record
-        r = requests.get(url, headers=headers)
-
-        return r
-
-def update_record(url, headers, payload):
-        #Add record
-        r = requests.put(url, headers=headers, json=payload)
-        if r.status_code != 201:
-          print('Record update failed with status code: %d' % r.status_code)
-          print(r.text)
-          sys.exit(2)
-        print ('Zone record updated.')
-
-        return r
-
-
-def main():
-  path = config_file
-  if not path.startswith('/'):
-    path = os.path.join(SCRIPT_DIR, path)
-  config = read_config(path)
-  if not config:
-    sys.exit("Please fill in the 'config.txt' file.")
-
-  for section in config.sections():
-  
-    #Retrieve API key
-    apikey = config.get(section, 'apikey')
-
-    #Set headers
-    headers = { 'Content-Type': 'application/json', 'X-Api-Key': '%s' % config.get(section, 'apikey')}
-
-    #Set URL
-    url = '%sdomains/%s/records/%s/A' % (config.get(section, 'api'), config.get(section, 'domain'), config.get(section, 'a_name'))
-    print(url)
-    #Discover External IP
-    external_ip = get_ip()
-    print('External IP is: %s' % external_ip)
-
-    #Prepare record
-    payload = {'rrset_ttl': config.get(section, 'ttl'), 'rrset_values': [external_ip]}
-
-    #Check current record
-    record = get_record(url, headers)
-
-    if record.status_code == 200:
-      print('Current record value is: %s' % json.loads(record.text)['rrset_values'][0])
-      if(json.loads(record.text)['rrset_values'][0] == external_ip):
-        print('No change in IP address. Goodbye.')
-        sys.exit()
-    else:
-      print('No existing record. Adding...')
-
-    update_record(url, headers, payload)
-
-if __name__ == "__main__":
-  main()
diff --git a/gandi.ddns.plist b/gandi.ddns.plist
new file mode 100644
index 0000000000000000000000000000000000000000..f99a8ca210c6fcbf19743046ac15736565a63e2f
--- /dev/null
+++ b/gandi.ddns.plist
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>gandi.ddns</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/usr/local/bin/python3</string>
+    <string>gandi_ddns.py</string>
+  </array>
+
+  <key>RunAtLoad</key>
+  <true/>
+
+  <key>StartInterval</key>
+  <integer>300</integer> <!-- every 5 mins -->
+
+  <key>KeepAlive</key>
+  <false/>
+
+  <key>WorkingDirectory</key>
+  <string>/usr/local/gandi-ddns/</string>
+
+  <key>StandardErrorPath</key>
+  <string>/usr/local/var/log/gandi-ddns/output.log</string>
+  <key>StandardOutPath</key>
+  <string>/usr/local/var/log/gandi-ddns/output.log</string>
+</dict>
+</plist>
diff --git a/gandi_ddns.py b/gandi_ddns.py
new file mode 100755
index 0000000000000000000000000000000000000000..8b05ebacd5745f2ea432f1b43698a654d0da709b
--- /dev/null
+++ b/gandi_ddns.py
@@ -0,0 +1,124 @@
+import configparser as configparser
+import sys
+import os
+import requests
+import json
+import ipaddress
+from datetime import datetime
+import time
+
+config_file = "config.txt"
+
+SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
+DEFAULT_RETRIES = 3
+
+
+class GandiDdnsError(Exception):
+        pass
+
+
+def get_ip_inner():
+        #Get external IP
+        try:
+                # Could be any service that just gives us a simple raw ASCII IP address (not HTML etc)
+                r = requests.get('https://api.ipify.org', timeout=3)
+        except requests.exceptions.RequestException:
+                raise GandiDdnsError('Failed to retrieve external IP.')
+        if r.status_code != 200:
+                raise GandiDdnsError(
+                        'Failed to retrieve external IP.'
+                        ' Server responded with status_code: %d' % r.status_code)
+
+        ip = r.text.rstrip() # strip \n and any trailing whitespace
+
+        if not(ipaddress.IPv4Address(ip)): # check if valid IPv4 address
+                raise GandiDdnsError('Got invalid IP: ' + ip)
+
+        return ip
+
+
+def get_ip(retries):
+        #Get external IP with retries
+
+        # Start at 5 seconds, double on every retry.
+        retry_delay_time = 5
+        for attempt in range(retries):
+                try:
+                        return get_ip_inner()
+                except GandiDdnsError as e:
+                        print('Getting external IP failed: %s' % e)
+                print('Waiting for %d seconds before trying again' % retry_delay_time)
+                time.sleep(retry_delay_time)
+                # Double retry time, cap at 60s.
+                retry_delay_time = min(60, 2 * retry_delay_time)
+        print('Exhausted retry attempts')
+        sys.exit(2)
+
+def read_config(config_path):
+        #Read configuration file
+        cfg = configparser.ConfigParser()
+        cfg.read(config_path)
+
+        return cfg
+
+def get_record(url, headers):
+        #Get existing record
+        r = requests.get(url, headers=headers)
+
+        return r
+
+def update_record(url, headers, payload):
+        #Add record
+        r = requests.put(url, headers=headers, json=payload)
+        if r.status_code != 201:
+          print(('Record update failed with status code: %d' % r.status_code))
+          print((r.text))
+          sys.exit(2)
+        print ('Zone record updated.')
+
+        return r
+
+
+def main():
+  path = config_file
+  if not path.startswith('/'):
+    path = os.path.join(SCRIPT_DIR, path)
+  config = read_config(path)
+  if not config:
+    sys.exit("Please fill in the 'config.txt' file.")
+
+  for section in config.sections():
+    print('%s - section %s' % (str(datetime.now()), section))
+
+    #Retrieve API key
+    apikey = config.get(section, 'apikey')
+
+    #Set headers
+    headers = { 'Content-Type': 'application/json', 'X-Api-Key': '%s' % config.get(section, 'apikey')}
+
+    #Set URL
+    url = '%sdomains/%s/records/%s/A' % (config.get(section, 'api'), config.get(section, 'domain'), config.get(section, 'a_name'))
+    print(url)
+    #Discover External IP
+    retries = int(config.get(section, 'retries', fallback=DEFAULT_RETRIES))
+    external_ip = get_ip(retries)
+    print(('External IP is: %s' % external_ip))
+
+    #Prepare record
+    payload = {'rrset_ttl': config.get(section, 'ttl'), 'rrset_values': [external_ip]}
+
+    #Check current record
+    record = get_record(url, headers)
+
+    if record.status_code == 200:
+      print(('Current record value is: %s' % json.loads(record.text)['rrset_values'][0]))
+      if(json.loads(record.text)['rrset_values'][0] == external_ip):
+        print('No change in IP address. Goodbye.')
+        continue
+    else:
+      print('No existing record. Adding...')
+
+    update_record(url, headers, payload)
+
+if __name__ == "__main__":
+  main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..b7ccff5492b2823f8ba350316fb980ae42418cd0
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+configparser
+ipaddress
+requests
\ No newline at end of file
diff --git a/test_gandi_ddns.py b/test_gandi_ddns.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8235a885ef6916f91cf97807f4338bd39668e9e
--- /dev/null
+++ b/test_gandi_ddns.py
@@ -0,0 +1,5 @@
+import gandi_ddns as script
+import requests
+
+def test_get_ip():
+    assert script.get_ip() == requests.get("http://ipecho.net/plain?").text
\ No newline at end of file